1// Copyright (C) 2024 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15/** 16 * VirtualCanvas - A Mithril Component for Virtual Canvas Rendering 17 * 18 * This module provides a Mithril component that acts as a scrolling container 19 * for tall and/or wide content. It overlays a floating canvas on top of its 20 * content rendered inside it, which stays in the viewport of scrolling 21 * container element as the user scrolls, allowing for rendering of large-scale 22 * visualizations which would be too large for a normal HTML canvas element. 23 * 24 * Key Features: 25 * - Supports horizontal, vertical, or both axes scrolling, moving the canvas 26 * while the user scrolls to keep it in the viewport. 27 * - Automatically handles canvas resizing using resize observers, including 28 * scaling for high DPI displays. 29 * - Calls a callback whenever the canvas needs to be redrawn. 30 */ 31 32import m from 'mithril'; 33import {DisposableStack} from '../base/disposable_stack'; 34import {findRef, toHTMLElement} from '../base/dom_utils'; 35import {Rect2D, Size2D} from '../base/geom'; 36import {assertExists} from '../base/logging'; 37import {VirtualCanvas} from '../base/virtual_canvas'; 38 39const CANVAS_CONTAINER_REF = 'canvas-container'; 40const CANVAS_OVERDRAW_PX = 300; 41const CANVAS_TOLERANCE_PX = 100; 42 43export interface VirtualOverlayCanvasDrawContext { 44 // Canvas rendering context. 45 readonly ctx: CanvasRenderingContext2D; 46 47 // The size of the virtual canvas element. 48 readonly virtualCanvasSize: Size2D; 49 50 // The rect of the actual canvas W.R.T to the virtual canvas element. 51 readonly canvasRect: Rect2D; 52} 53 54export type Overflow = 'hidden' | 'visible' | 'auto'; 55 56export interface VirtualOverlayCanvasAttrs { 57 // Additional class names applied to the root element. 58 readonly className?: string; 59 60 // Overflow rules for the horizontal axis. 61 readonly overflowX?: Overflow; 62 63 // Overflow rules for the vertical axis. 64 readonly overflowY?: Overflow; 65 66 // Called when the canvas needs to be repainted due to a layout shift or 67 // or resize. 68 onCanvasRedraw?(ctx: VirtualOverlayCanvasDrawContext): void; 69 70 // When true the canvas will not be redrawn on mithril update cycles. Disable 71 // this if you want to manage the canvas redraw cycle yourself (i.e. possibly 72 // using raf.addCanvasRedrawCallback()) and want to avoid double redraws. 73 // Default: false. 74 readonly disableCanvasRedrawOnMithrilUpdates?: boolean; 75 76 // Called when the canvas is mounted. The passed redrawCanvas() function can 77 // be called to redraw the canvas synchronously at any time. Any returned 78 // disposable will be disposed of when the component is removed. 79 onMount?(redrawCanvas: () => void): Disposable | void; 80} 81 82function getScrollAxesFromOverflow(x: Overflow, y: Overflow) { 83 if (x === 'auto' && y === 'auto') { 84 return 'both'; 85 } else if (x === 'auto') { 86 return 'x'; 87 } else if (y === 'auto') { 88 return 'y'; 89 } else { 90 // 91 return 'none'; 92 } 93} 94 95// This mithril component acts as scrolling container for tall and/or wide 96// content. Adds a virtually scrolling canvas over the top of any child elements 97// rendered inside it. 98export class VirtualOverlayCanvas 99 implements m.ClassComponent<VirtualOverlayCanvasAttrs> 100{ 101 readonly trash = new DisposableStack(); 102 private ctx?: CanvasRenderingContext2D; 103 private virtualCanvas?: VirtualCanvas; 104 private attrs?: VirtualOverlayCanvasAttrs; 105 106 view({attrs, children}: m.CVnode<VirtualOverlayCanvasAttrs>) { 107 this.attrs = attrs; 108 const {overflowX = 'visible', overflowY = 'visible'} = attrs; 109 110 return m( 111 '.pf-virtual-overlay-canvas', // The scrolling container 112 { 113 className: attrs.className, 114 style: { 115 overflowX, 116 overflowY, 117 }, 118 }, 119 m( 120 '.pf-virtual-overlay-canvas__content', // Container for scrolling element, used for sizing the canvas 121 children, 122 // Put canvas container after content so it appears on top. An actual 123 // canvas element will be created inside here by the 124 // VirtualCanvasHelper. 125 m('.pf-virtual-overlay-canvas__canvas-container', { 126 ref: CANVAS_CONTAINER_REF, 127 }), 128 ), 129 ); 130 } 131 132 oncreate({attrs, dom}: m.CVnodeDOM<VirtualOverlayCanvasAttrs>) { 133 const canvasContainerElement = toHTMLElement( 134 assertExists(findRef(dom, CANVAS_CONTAINER_REF)), 135 ); 136 const {overflowX = 'visible', overflowY = 'visible'} = attrs; 137 138 // Create the virtual canvas inside the canvas container element. We assume 139 // the scrolling container is the root level element of this component so we 140 // can just use `dom`. 141 const virtualCanvas = new VirtualCanvas(canvasContainerElement, dom, { 142 overdrawPx: CANVAS_OVERDRAW_PX, 143 tolerancePx: CANVAS_TOLERANCE_PX, 144 overdrawAxes: getScrollAxesFromOverflow(overflowX, overflowY), 145 }); 146 this.trash.use(virtualCanvas); 147 this.virtualCanvas = virtualCanvas; 148 149 // Create the canvas rendering context 150 this.ctx = assertExists(virtualCanvas.canvasElement.getContext('2d')); 151 152 // When the container resizes, we might need to resize the canvas. This can 153 // be slow so we don't want to do it every render cycle. VirtualCanvas will 154 // tell us when we need to do this. 155 virtualCanvas.setCanvasResizeListener((canvas, width, height) => { 156 const dpr = window.devicePixelRatio; 157 canvas.width = width * dpr; 158 canvas.height = height * dpr; 159 }); 160 161 // Whenever the canvas changes size or moves around (e.g. when scrolling), 162 // we'll need to trigger a re-render to keep canvas content aligned with the 163 // DOM elements underneath. 164 virtualCanvas.setLayoutShiftListener(() => { 165 this.redrawCanvas(); 166 }); 167 168 const disposable = attrs.onMount?.(this.redrawCanvas.bind(this)); 169 disposable && this.trash.use(disposable); 170 171 !attrs.disableCanvasRedrawOnMithrilUpdates && this.redrawCanvas(); 172 } 173 174 onupdate({attrs}: m.CVnodeDOM<VirtualOverlayCanvasAttrs>) { 175 !attrs.disableCanvasRedrawOnMithrilUpdates && this.redrawCanvas(); 176 } 177 178 onremove() { 179 this.trash.dispose(); 180 } 181 182 private redrawCanvas() { 183 const ctx = assertExists(this.ctx); 184 const virtualCanvas = assertExists(this.virtualCanvas); 185 186 // Reset & clear canvas 187 ctx.resetTransform(); 188 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 189 190 // Adjust scaling according pixel ratio. This makes sure the canvas remains 191 // sharp on high DPI screens. 192 const dpr = window.devicePixelRatio; 193 ctx.scale(dpr, dpr); 194 195 // Align canvas rendering offset with the canvas container, not the actual 196 // canvas. This means we can ignore the fact that we are using a virtual 197 // canvas and just render assuming (0, 0) is at the top left of the canvas 198 // container. 199 ctx.translate( 200 -virtualCanvas.canvasRect.left, 201 -virtualCanvas.canvasRect.top, 202 ); 203 204 assertExists(this.attrs).onCanvasRedraw?.({ 205 ctx, 206 virtualCanvasSize: virtualCanvas.size, 207 canvasRect: virtualCanvas.canvasRect, 208 }); 209 } 210} 211