| // Copyright (C) 2026 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| // Canvas 2D fallback implementation of Renderer for when WebGL is unavailable. |
| // All transforms are applied via the canvas context's transform matrix |
| // (translate/scale), so draw methods use coordinates directly. |
| |
| import type {Color} from './color'; |
| import {type Transform1D, Transform2D} from './geom'; |
| import { |
| type Renderer, |
| RECT_PATTERN_HATCHED, |
| RECT_PATTERN_FADE_RIGHT, |
| type MarkerRenderFunc, |
| type MarkerBuffers, |
| type StepAreaBuffers, |
| type SliceBuffers, |
| rowTopFromLayout, |
| rowHeightFromLayout, |
| type RowLayout, |
| SLICE_GAP_PX, |
| } from './renderer'; |
| |
| // Clip bounds stored in physical screen coordinates (post-transform). |
| // This allows correct culling regardless of what transforms are active. |
| interface PhysicalClipBounds { |
| left: number; |
| top: number; |
| right: number; |
| bottom: number; |
| } |
| |
| export class Canvas2DRenderer implements Renderer { |
| private readonly ctx: CanvasRenderingContext2D; |
| // Track transform ourselves for CPU-side culling calculations. |
| private transform = Transform2D.Identity; |
| private physicalClipBounds?: PhysicalClipBounds; |
| |
| constructor(ctx: CanvasRenderingContext2D) { |
| this.ctx = ctx; |
| } |
| |
| pushTransform(t: Partial<Transform2D>): Disposable { |
| const {offsetX = 0, offsetY = 0, scaleX = 1, scaleY = 1} = t; |
| const ctx = this.ctx; |
| |
| const previousTransform = this.transform; |
| this.transform = Transform2D.compose(this.transform, t); |
| |
| ctx.save(); |
| ctx.translate(offsetX, offsetY); |
| ctx.scale(scaleX, scaleY); |
| |
| return { |
| [Symbol.dispose]: () => { |
| ctx.restore(); |
| this.transform = previousTransform; |
| }, |
| }; |
| } |
| |
| drawMarkers( |
| buffers: MarkerBuffers, |
| rowLayout: RowLayout, |
| markerWidth: number, |
| xTransform: Transform1D, |
| render: MarkerRenderFunc, |
| ): void { |
| const {xs, depths, colors, count} = buffers; |
| const ctx = this.ctx; |
| const clip = this.physicalClipBounds; |
| const t = this.transform; |
| const {offset, scale} = xTransform; |
| let previousColor: number | undefined = undefined; |
| |
| for (let i = 0; i < count; i++) { |
| // Transform X from data space to screen space (centered) |
| const screenX = xs[i] * scale + offset; |
| const y = rowTopFromLayout(rowLayout, depths[i]); |
| const h = rowHeightFromLayout(rowLayout, depths[i]); |
| |
| // CPU-side culling |
| if (clip !== undefined) { |
| const physLeft = t.offsetX + (screenX - markerWidth / 2) * t.scaleX; |
| const physRight = t.offsetX + (screenX + markerWidth / 2) * t.scaleX; |
| const physTop = t.offsetY + y * t.scaleY; |
| const physBottom = t.offsetY + (y + h) * t.scaleY; |
| if ( |
| physRight < clip.left || |
| physLeft > clip.right || |
| physBottom < clip.top || |
| physTop > clip.bottom |
| ) { |
| continue; |
| } |
| } |
| |
| // Convert packed RGBA (0xRRGGBBAA) to CSS string |
| const rgba = colors[i]; |
| if (previousColor !== rgba) { |
| const r = (rgba >> 24) & 0xff; |
| const g = (rgba >> 16) & 0xff; |
| const b = (rgba >> 8) & 0xff; |
| const a = (rgba & 0xff) / 255; |
| const cssColor = `rgba(${r},${g},${b},${a})`; |
| ctx.fillStyle = cssColor; |
| previousColor = rgba; |
| } |
| |
| render(ctx, screenX - markerWidth / 2, y, markerWidth, h); |
| } |
| } |
| |
| drawSlices( |
| buffers: SliceBuffers, |
| rowLayout: RowLayout, |
| xTransform: Transform1D, |
| ): void { |
| const {starts, ends, depths, colors, patterns, count} = buffers; |
| const ctx = this.ctx; |
| const clip = this.physicalClipBounds; |
| const t = this.transform; |
| const {offset, scale} = xTransform; |
| let previousColor: number | undefined = undefined; |
| |
| for (let i = 0; i < count; i++) { |
| const depth = depths[i]; |
| |
| // Compute row position from two-tier formula |
| const y = rowTopFromLayout(rowLayout, depth); |
| const h = rowHeightFromLayout(rowLayout, depth); |
| |
| // Transform X from data coordinates to screen coordinates |
| const startPx = starts[i] * scale + offset; |
| const rawEndPx = ends[i] * scale + offset; |
| // Inset the right edge so adjacent slices stay visually distinct. |
| const endPx = Math.max(startPx + 1, rawEndPx - SLICE_GAP_PX); |
| const w = Math.max(endPx - startPx, 1); |
| |
| // CPU-side culling and clamping to clip bounds |
| let drawX = startPx; |
| let drawY = y; |
| let drawW = w; |
| let drawH = h; |
| |
| if (clip !== undefined) { |
| const physLeft = t.offsetX + startPx * t.scaleX; |
| const physRight = t.offsetX + endPx * t.scaleX; |
| const physTop = t.offsetY + y * t.scaleY; |
| const physBottom = t.offsetY + (y + h) * t.scaleY; |
| |
| // Cull if completely outside |
| if ( |
| physRight < clip.left || |
| physLeft > clip.right || |
| physBottom < clip.top || |
| physTop > clip.bottom |
| ) { |
| continue; |
| } |
| |
| // Clamp to clip bounds (in physical space, then convert back to screen space) |
| const cPhysLeft = Math.max(physLeft, clip.left); |
| const cPhysRight = Math.min(physRight, clip.right); |
| const cPhysTop = Math.max(physTop, clip.top); |
| const cPhysBottom = Math.min(physBottom, clip.bottom); |
| |
| // Convert clamped physical coords back to screen space |
| drawX = (cPhysLeft - t.offsetX) / t.scaleX; |
| drawY = (cPhysTop - t.offsetY) / t.scaleY; |
| drawW = (cPhysRight - cPhysLeft) / t.scaleX; |
| drawH = (cPhysBottom - cPhysTop) / t.scaleY; |
| } |
| |
| // Convert packed RGBA (0xRRGGBBAA) to CSS string |
| const rgba = colors[i]; |
| if (previousColor !== rgba) { |
| const r = (rgba >> 24) & 0xff; |
| const g = (rgba >> 16) & 0xff; |
| const b = (rgba >> 8) & 0xff; |
| const a = (rgba & 0xff) / 255; |
| const cssColor = `rgba(${r},${g},${b},${a})`; |
| ctx.fillStyle = cssColor; |
| previousColor = rgba; |
| } |
| ctx.fillRect(drawX, drawY, drawW, drawH); |
| |
| const flags = patterns[i]; |
| if (flags & RECT_PATTERN_HATCHED && w >= 5) { |
| ctx.fillStyle = getHatchedPattern(ctx); |
| ctx.fillRect(drawX, drawY, drawW, drawH); |
| previousColor = undefined; |
| } |
| |
| if (flags & RECT_PATTERN_FADE_RIGHT && w >= 5) { |
| ctx.save(); |
| ctx.globalCompositeOperation = 'destination-out'; |
| // Fade ends at the clamped right edge |
| const gradient = ctx.createLinearGradient( |
| drawX, |
| drawY, |
| drawX + drawW, |
| drawY, |
| ); |
| gradient.addColorStop(0.66, 'rgba(0, 0, 0, 0)'); |
| gradient.addColorStop(1.0, 'rgba(0, 0, 0, 1)'); |
| ctx.fillStyle = gradient; |
| ctx.fillRect(drawX, drawY, drawW, drawH); |
| ctx.restore(); |
| previousColor = undefined; |
| } |
| } |
| } |
| |
| drawStepArea( |
| buffers: StepAreaBuffers, |
| transform: Transform2D, |
| color: Color, |
| ): void { |
| const {xs, ys, minYs, maxYs, fillAlpha, xnext, count} = buffers; |
| if (count < 1) return; |
| |
| const ctx = this.ctx; |
| const clip = this.physicalClipBounds; |
| const baselineY = transform.offsetY; |
| const strokeColor = color.setAlpha(1.0); |
| |
| // Transform functions: screenCoord = raw * scale + offset |
| const tx = (x: number) => x * transform.scaleX + transform.offsetX; |
| const ty = (y: number) => y * transform.scaleY + transform.offsetY; |
| |
| ctx.fillStyle = color.cssString; |
| ctx.strokeStyle = strokeColor.cssString; |
| ctx.beginPath(); |
| |
| for (let i = 0; i < count; i++) { |
| // Compute segment bounds |
| const x = Math.round(tx(xs[i])); |
| const nextX = Math.round(tx(xnext[i])); |
| |
| // Don't render segments that are fully outside the clip region |
| if (clip) { |
| const physX = this.transform.offsetX + x * this.transform.scaleX; |
| const physNextX = |
| this.transform.offsetX + nextX * this.transform.scaleX; |
| // Skip segments entirely off the left edge |
| if (physNextX < clip.left) continue; |
| // Stop once we're past the right edge |
| if (physX >= clip.right) break; |
| } |
| |
| const y = ty(ys[i]); |
| const minY = ty(minYs[i]); |
| const maxY = ty(maxYs[i]); |
| const fill = fillAlpha[i]; |
| |
| // If fillAlpha is close to zero, don't draw anything at all |
| if (fill >= 0.01) { |
| const width = nextX - x; |
| const height = baselineY - y; |
| ctx.globalAlpha = fill; |
| ctx.fillRect(x, y, width, height); |
| } |
| |
| // Draws a sideways T (range indicator) at the transition x: |
| // |
| // maxY + (Top of range) |
| // | |
| // y +-------+ (nextX, y) |
| // | |
| // minY + (Bottom of range) |
| ctx.moveTo(x, maxY); |
| ctx.lineTo(x, minY); |
| ctx.moveTo(x, y); |
| ctx.lineTo(nextX, y); |
| } |
| |
| ctx.globalAlpha = 1.0; |
| ctx.stroke(); |
| } |
| |
| clip(x: number, y: number, w: number, h: number): Disposable { |
| const ctx = this.ctx; |
| |
| // Store clip bounds in physical coordinates for CPU-side culling |
| const t = this.transform; |
| const physX = t.offsetX + x * t.scaleX; |
| const physY = t.offsetY + y * t.scaleY; |
| const physW = w * t.scaleX; |
| const physH = h * t.scaleY; |
| |
| const previousClipBounds = this.physicalClipBounds; |
| this.physicalClipBounds = { |
| left: physX, |
| top: physY, |
| right: physX + physW, |
| bottom: physY + physH, |
| }; |
| |
| ctx.save(); |
| ctx.beginPath(); |
| ctx.rect(x, y, w, h); |
| ctx.clip(); |
| |
| return { |
| [Symbol.dispose]: () => { |
| ctx.restore(); |
| this.physicalClipBounds = previousClipBounds; |
| }, |
| }; |
| } |
| |
| resetTransform(): void { |
| this.ctx.resetTransform(); |
| this.transform = Transform2D.Identity; |
| } |
| |
| clear(): void { |
| const ctx = this.ctx; |
| const canvas = ctx.canvas; |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| } |
| } |
| |
| // Creates a diagonal hatched pattern for distinguishing slices with real-time |
| // priorities. The pattern is created once as an offscreen canvas and cached |
| // on the main canvas context. |
| function getHatchedPattern(ctx: CanvasRenderingContext2D): CanvasPattern { |
| const mctx = ctx as CanvasRenderingContext2D & { |
| sliceHatchedPattern?: CanvasPattern; |
| }; |
| if (mctx.sliceHatchedPattern !== undefined) return mctx.sliceHatchedPattern; |
| |
| const canvas = document.createElement('canvas'); |
| const SIZE = 8; |
| canvas.width = canvas.height = SIZE; |
| const patternCtx = canvas.getContext('2d')!; |
| patternCtx.strokeStyle = 'rgba(255,255,255,0.3)'; |
| patternCtx.beginPath(); |
| patternCtx.lineWidth = 1; |
| patternCtx.moveTo(0, SIZE); |
| patternCtx.lineTo(SIZE, 0); |
| patternCtx.stroke(); |
| mctx.sliceHatchedPattern = mctx.createPattern(canvas, 'repeat')!; |
| return mctx.sliceHatchedPattern; |
| } |