diff --git a/apps/web/src/lib/components/GraphCanvas.svelte b/apps/web/src/lib/components/GraphCanvas.svelte index 9d2da23..78657a2 100644 --- a/apps/web/src/lib/components/GraphCanvas.svelte +++ b/apps/web/src/lib/components/GraphCanvas.svelte @@ -2,19 +2,11 @@ import { onMount, untrack } from 'svelte'; import { planSpatialComposite, type MathRect } from '$lib/engine/compositor'; - import { - clipPolygonToBounds, - clipSegmentToBounds, - type MathPoint - } from '$lib/engine/geometry-clipping'; - import { writeMaskRgba } from '$lib/engine/mask-rgba'; - import { recordScenePainted, recordSceneRetention } from '$lib/engine/scene-telemetry'; - import type { RenderScene2d, SceneLayer, Viewport } from '$lib/engine/types'; + import { recordScenePainted } from '$lib/engine/scene-telemetry'; + import type { RenderScene2d, Viewport } from '$lib/engine/types'; import type { SpatialTileResult } from '$lib/engine/worker-protocol'; - import { recordTilePainted, recordTileRetention } from '$lib/engine/tile-telemetry'; + import { recordTilePainted } from '$lib/engine/tile-telemetry'; import { - spatialResourceKey, - tileCoverage, visibleTileCoverage, type SpatialDemandTarget } from '$lib/engine/tiles'; @@ -25,12 +17,20 @@ type ExpressionAppearance } from '$lib/graph/expression-appearance'; import { - mathToPixel, - panViewport, - pinchViewport, - polylineRanges, - zoomViewport - } from '$lib/graph/viewport'; + clipMathRegions, + expandMathRegionsByPixels, + intersectsAnyMathRegion, + mathRectArea, + sameViewport, + sceneToPresentationTransform, + uncoveredMathRectArea + } from '$lib/graph/canvas/geometry'; + import { + RetainedSceneResources, + RetainedTileResources + } from '$lib/graph/canvas/retained-resources'; + import { drawGrid } from '$lib/graph/canvas/grid-renderer'; + import { mathToPixel, panViewport, pinchViewport, zoomViewport } from '$lib/graph/viewport'; let { scene, @@ -76,27 +76,8 @@ let publishFrame: number | undefined; let pendingPublishedViewport: Viewport | undefined; let pendingCameraInputAt: number | undefined; - let retainedScene: RenderScene2d | null = null; - let retainedLayers: RetainedLayer[] = []; - let retainedTileLayers: Array< - RetainedLayer & { resource: SpatialTileResult['resource']; resultIndex: number } - > = []; - let retainedTileMasks: Array<{ - canvas: HTMLCanvasElement; - sourceViewport: Viewport; - resource: SpatialTileResult['resource']; - resultIndex: number; - }> = []; - type RetainedTileMask = (typeof retainedTileMasks)[number]; - type CachedTileLayer = RetainedLayer & { resource: SpatialTileResult['resource'] }; - const retainedTileResourceCache = new Map< - string, - { masks: Omit[]; layers: CachedTileLayer[] } - >(); - const MAX_RETAINED_TILE_RESOURCES = 128; - let retainedSpatialTileResults: SpatialTileResult[] = []; - const retainedPathCache = new Map(); - const MAX_RETAINED_PATHS = 64; + const retainedScene = new RetainedSceneResources(); + const retainedTiles = new RetainedTileResources(); let pinch: | { pointerIds: [number, number]; @@ -106,33 +87,11 @@ } | undefined; - type RetainedLayer = - | RetainedLayerOfType<'fill'> - | RetainedLayerOfType<'points'> - | RetainedLayerOfType<'polyline'>; - type RetainedLayerOfType = { - type: Type; - path: Path2D; - sourceViewport: Viewport; - layer: Extract; - }; - function canvasPoint(clientX: number, clientY: number) { const rect = canvas.getBoundingClientRect(); return { x: clientX - rect.left, y: clientY - rect.top }; } - function sameViewport(left: Viewport, right: Viewport) { - return ( - left.x_min === right.x_min && - left.x_max === right.x_max && - left.y_min === right.y_min && - left.y_max === right.y_max && - left.width === right.width && - left.height === right.height - ); - } - function plotAppearance(expressionId: string) { return expressionAppearanceFor(expressionAppearances, expressionId); } @@ -193,410 +152,6 @@ }); } - function retainSpatialTiles(tiles: SpatialTileResult[]): MathRect[] { - const startedAt = performance.now(); - let createdCount = 0; - let reusedCount = 0; - const completeResourceKeys = new Set( - tiles.filter(({ complete }) => complete).map(({ resource }) => spatialResourceKey(resource)) - ); - for (const key of retainedTileResourceCache.keys()) { - const separator = key.lastIndexOf(':'); - const baseKey = separator === -1 ? key : key.slice(0, separator); - if (key.endsWith(':complete') || !completeResourceKeys.has(baseKey)) continue; - retainedTileResourceCache.delete(key); - } - - const retained = tiles.map((tile) => { - const key = retainedTileResultKey(tile); - const cached = retainedTileResourceCache.get(key); - if (cached) { - reusedCount += 1; - retainedTileResourceCache.delete(key); - retainedTileResourceCache.set(key, cached); - return cached; - } - createdCount += 1; - const created = retainSpatialTile(tile); - retainedTileResourceCache.set(key, created); - return created; - }); - while (retainedTileResourceCache.size > MAX_RETAINED_TILE_RESOURCES) { - const oldest = retainedTileResourceCache.keys().next().value; - if (oldest === undefined) break; - retainedTileResourceCache.delete(oldest); - } - retainedTileMasks = retained.flatMap(({ masks }, resultIndex) => - masks.map((mask) => ({ ...mask, resultIndex })) - ); - retainedTileLayers = retained.flatMap(({ layers }, resultIndex) => - layers.map((layer) => ({ ...layer, resultIndex })) - ); - const retainedAt = performance.now(); - for (const tile of tiles) recordTileRetention(tile, retainedAt); - performance.clearMeasures('cartesium:tile-retain'); - performance.measure('cartesium:tile-retain', { - start: 0, - duration: performance.now() - startedAt, - detail: { - resourceCount: tiles.length, - createdCount, - reusedCount, - cachedResourceCount: retainedTileResourceCache.size - } - }); - const previous = new Map( - retainedSpatialTileResults.map((tile) => [retainedTileResultKey(tile), tile]) - ); - const next = new Map(tiles.map((tile) => [retainedTileResultKey(tile), tile])); - const dirtyRegions = [ - ...retainedSpatialTileResults - .filter((tile) => !next.has(retainedTileResultKey(tile))) - .map(spatialTilePresentableCoverage), - ...tiles - .filter((tile) => !previous.has(retainedTileResultKey(tile))) - .map(spatialTilePresentableCoverage) - ]; - retainedSpatialTileResults = tiles; - return dirtyRegions; - } - - function spatialTilePresentableCoverage(tile: SpatialTileResult): MathRect { - return tile.coverage ?? tileCoverage(tile.resource, 0); - } - - function retainedTileResultKey(tile: SpatialTileResult) { - return `${spatialResourceKey(tile.resource)}:${tile.complete ? 'complete' : `part-${tile.partIndex ?? 0}`}`; - } - - function retainSpatialTile(tile: SpatialTileResult): { - masks: Omit[]; - layers: CachedTileLayer[]; - } { - const masks = (() => { - if (!tile.mask) return []; - const backing = document.createElement('canvas'); - backing.width = tile.mask.width; - backing.height = tile.mask.height; - const backingContext = backing.getContext('2d'); - if (!backingContext) return []; - const image = backingContext.createImageData(tile.mask.width, tile.mask.height); - const rgb = parseHexColor(tile.mask.color); - writeMaskRgba(image.data, tile.mask.alpha, rgb); - backingContext.putImageData(image, 0, 0); - return [{ canvas: backing, sourceViewport: tile.scene.viewport, resource: tile.resource }]; - })(); - const layers = - [ - ...tile.scene.layers - .filter( - (layer) => - tile.resource.representation === 'inequality-mask' || - (layer.type === 'polyline' && layer.source_kind === 'implicit') - ) - .map((layer) => ({ - ...retainLayer(layer, tile.scene.viewport), - resource: tile.resource, - })), - ...(tile.polylines ?? []).map((polyline) => ({ - ...retainPackedPolyline(polyline, tile.scene.viewport), - resource: tile.resource, - })) - ]; - return { masks, layers }; - } - - function parseHexColor(color: string): [number, number, number] { - const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); - return match - ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] - : [37, 99, 235]; - } - - function retainScene(nextScene: RenderScene2d | null) { - const startedAt = performance.now(); - let compiledPathCount = 0; - retainedScene = nextScene; - retainedLayers = nextScene - ? nextScene.layers.map((layer) => { - const cached = retainedPathCache.get(layer.geometry_id); - if (cached) { - retainedPathCache.delete(layer.geometry_id); - retainedPathCache.set(layer.geometry_id, cached); - return { type: layer.type, layer, ...cached } as RetainedLayer; - } - compiledPathCount += 1; - const retained = retainLayer(layer, nextScene.viewport, nextScene.coverage); - retainedPathCache.set(layer.geometry_id, { - path: retained.path, - sourceViewport: retained.sourceViewport - }); - return retained; - }) - : []; - while (retainedPathCache.size > MAX_RETAINED_PATHS) { - const oldest = retainedPathCache.keys().next().value; - if (oldest === undefined) break; - retainedPathCache.delete(oldest); - } - const retainedAt = performance.now(); - if (nextScene) recordSceneRetention(nextScene, startedAt, retainedAt); - performance.clearMeasures('cartesium:scene-retain'); - performance.measure('cartesium:scene-retain', { - start: 0, - duration: retainedAt - startedAt, - detail: { - layerCount: retainedLayers.length, - compiledPathCount, - reusedPathCount: retainedLayers.length - compiledPathCount, - cachedPathCount: retainedPathCache.size - } - }); - } - - function retainLayer( - layer: SceneLayer, - sourceViewport: Viewport, - clipBounds = sourceViewport - ): RetainedLayer { - const path = new Path2D(); - if (layer.type === 'fill') { - for (const [xMin, xMax, yMin, yMax] of layer.rectangles ?? []) { - const clippedXMin = Math.max(xMin, clipBounds.x_min); - const clippedXMax = Math.min(xMax, clipBounds.x_max); - const clippedYMin = Math.max(yMin, clipBounds.y_min); - const clippedYMax = Math.min(yMax, clipBounds.y_max); - if (clippedXMin >= clippedXMax || clippedYMin >= clippedYMax) continue; - const topLeft = mathToPixel(sourceViewport, clippedXMin, clippedYMax); - const bottomRight = mathToPixel(sourceViewport, clippedXMax, clippedYMin); - path.rect( - topLeft.x, - topLeft.y, - bottomRight.x - topLeft.x, - bottomRight.y - topLeft.y - ); - } - for (const polygon of layer.polygons ?? []) { - const clipped = clipPolygonToBounds(polygon, clipBounds); - for (let index = 0; index < clipped.length; index += 1) { - const point = mathToPixel(sourceViewport, clipped[index][0], clipped[index][1]); - if (index === 0) path.moveTo(point.x, point.y); - else path.lineTo(point.x, point.y); - } - if (clipped.length > 0) path.closePath(); - } - return { type: 'fill', path, sourceViewport, layer }; - } - - if (layer.type === 'points') { - for (const [x, y] of layer.points) { - if ( - !Number.isFinite(x) || - !Number.isFinite(y) || - x < clipBounds.x_min || - x > clipBounds.x_max || - y < clipBounds.y_min || - y > clipBounds.y_max - ) continue; - const point = mathToPixel(sourceViewport, x, y); - path.moveTo(point.x + 4.5, point.y); - path.arc(point.x, point.y, 4.5, 0, Math.PI * 2); - } - return { type: 'points', path, sourceViewport, layer }; - } - - for (const [start, end] of polylineRanges(layer)) { - if (end - start < 2) continue; - if (layer.source_kind === 'implicit') { - appendPresentationSmoothedRange(path, layer.vertices, start, end, sourceViewport); - continue; - } - appendClippedPolyline(path, layer.vertices, start, end, sourceViewport, clipBounds); - } - return { type: 'polyline', path, sourceViewport, layer }; - } - - type PixelPoint = { x: number; y: number }; - - function appendClippedPolyline( - path: Path2D, - vertices: MathPoint[], - start: number, - end: number, - viewport: Viewport, - clipBounds: Viewport - ) { - let previousEnd: MathPoint | undefined; - for (let index = start + 1; index < end; index += 1) { - const clipped = clipSegmentToBounds(vertices[index - 1], vertices[index], clipBounds); - if (!clipped) { - previousEnd = undefined; - continue; - } - const [segmentStart, segmentEnd] = clipped; - if (!previousEnd || !sameMathPoint(previousEnd, segmentStart)) { - const point = mathToPixel(viewport, segmentStart[0], segmentStart[1]); - path.moveTo(point.x, point.y); - } - const point = mathToPixel(viewport, segmentEnd[0], segmentEnd[1]); - path.lineTo(point.x, point.y); - previousEnd = segmentEnd; - } - } - - function sameMathPoint(left: MathPoint, right: MathPoint) { - return left[0] === right[0] && left[1] === right[1]; - } - - function appendPresentationSmoothedRange( - path: Path2D, - vertices: [number, number][], - start: number, - end: number, - viewport: Viewport - ) { - const points = vertices.slice(start, end).map(([x, y]) => mathToPixel(viewport, x, y)); - appendPresentationSmoothedPoints(path, points); - } - - function appendPresentationSmoothedPoints(path: Path2D, points: PixelPoint[]) { - if (points.length < 2) return; - path.moveTo(points[0].x, points[0].y); - for (let index = 1; index < points.length - 1; index += 1) { - const previous = points[index - 1]; - const current = points[index]; - const next = points[index + 1]; - if (!isGentleTurn(previous, current, next)) { - path.lineTo(current.x, current.y); - continue; - } - path.quadraticCurveTo( - current.x, - current.y, - (current.x + next.x) / 2, - (current.y + next.y) / 2 - ); - } - const last = points[points.length - 1]; - path.lineTo(last.x, last.y); - } - - function isGentleTurn(previous: PixelPoint, current: PixelPoint, next: PixelPoint) { - const incomingX = current.x - previous.x; - const incomingY = current.y - previous.y; - const outgoingX = next.x - current.x; - const outgoingY = next.y - current.y; - const incomingLength = Math.hypot(incomingX, incomingY); - const outgoingLength = Math.hypot(outgoingX, outgoingY); - if (incomingLength === 0 || outgoingLength === 0) return false; - const cosine = - (incomingX * outgoingX + incomingY * outgoingY) / (incomingLength * outgoingLength); - // Preserve deliberate corners such as |x| + |y| = 1. Small marching-squares - // direction changes are presentation artifacts and can be rounded cheaply. - return cosine >= Math.cos(Math.PI / 6); - } - - function retainPackedPolyline( - polyline: NonNullable[number], - sourceViewport: Viewport - ): RetainedLayerOfType<'polyline'> { - const path = new Path2D(); - const vertexCount = polyline.coordinates.length / 2; - let start = 0; - for (const end of [...polyline.breaks, vertexCount]) { - if (end - start >= 2) { - if (polyline.layer.source_kind === 'implicit') { - const points: PixelPoint[] = []; - for (let index = start; index < end; index += 1) { - points.push( - mathToPixel( - sourceViewport, - polyline.coordinates[index * 2], - polyline.coordinates[index * 2 + 1] - ) - ); - } - appendPresentationSmoothedPoints(path, points); - start = end; - continue; - } - for (let index = start; index < end; index += 1) { - const point = mathToPixel( - sourceViewport, - polyline.coordinates[index * 2], - polyline.coordinates[index * 2 + 1] - ); - if (index === start) path.moveTo(point.x, point.y); - else path.lineTo(point.x, point.y); - } - } - start = end; - } - return { - type: 'polyline', - path, - sourceViewport, - layer: { ...polyline.layer, vertices: [], breaks: [] } - }; - } - - function sceneToPresentationTransform(source: Viewport, target: Viewport) { - const sourceXSpan = source.x_max - source.x_min; - const sourceYSpan = source.y_max - source.y_min; - const targetXSpan = target.x_max - target.x_min; - const targetYSpan = target.y_max - target.y_min; - return { - xScale: (sourceXSpan / source.width) * (target.width / targetXSpan), - yScale: (sourceYSpan / source.height) * (target.height / targetYSpan), - xOffset: ((source.x_min - target.x_min) / targetXSpan) * target.width, - yOffset: ((target.y_max - source.y_max) / targetYSpan) * target.height - }; - } - - function clipMathRegions( - ctx: CanvasRenderingContext2D, - regions: MathRect[], - sourceViewport: Viewport - ) { - ctx.beginPath(); - for (const region of regions) { - const topLeft = mathToPixel(sourceViewport, region.x_min, region.y_max); - const bottomRight = mathToPixel(sourceViewport, region.x_max, region.y_min); - ctx.rect( - topLeft.x, - topLeft.y, - bottomRight.x - topLeft.x, - bottomRight.y - topLeft.y - ); - } - ctx.clip(); - } - - function expandMathRegionsByPixels( - regions: MathRect[], - viewport: Viewport, - pixels: number - ): MathRect[] { - const xPadding = ((viewport.x_max - viewport.x_min) / viewport.width) * pixels; - const yPadding = ((viewport.y_max - viewport.y_min) / viewport.height) * pixels; - return regions.map((region) => ({ - x_min: Math.max(viewport.x_min, region.x_min - xPadding), - x_max: Math.min(viewport.x_max, region.x_max + xPadding), - y_min: Math.max(viewport.y_min, region.y_min - yPadding), - y_max: Math.min(viewport.y_max, region.y_max + yPadding) - })); - } - - function intersectsAnyMathRegion(region: MathRect, candidates: MathRect[]) { - return candidates.some( - (candidate) => - region.x_min < candidate.x_max && - candidate.x_min < region.x_max && - region.y_min < candidate.y_max && - candidate.y_min < region.y_max - ); - } - function beginPinch() { const [first, second] = [...pointers.entries()].slice(0, 2); if (!first || !second) return; @@ -611,111 +166,6 @@ drag = undefined; } - function niceGridStep(span: number, pixels: number): number { - const rough = span / Math.max(2, pixels / 80); - const power = 10 ** Math.floor(Math.log10(rough)); - const fraction = rough / power; - const multiple = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; - return multiple * power; - } - - function formatTick(value: number, step: number): string { - if (Math.abs(value) < step * 1e-8) return '0'; - if (Math.abs(value) >= 1e5 || Math.abs(value) < 1e-4) return value.toExponential(1); - const decimals = Math.max(0, -Math.floor(Math.log10(step))); - return value.toFixed(Math.min(decimals, 6)); - } - - function drawGrid(ctx: CanvasRenderingContext2D, renderViewport: Viewport) { - const dark = appearance.theme === 'dark'; - const xStep = niceGridStep(renderViewport.x_max - renderViewport.x_min, renderViewport.width); - const yStep = niceGridStep(renderViewport.y_max - renderViewport.y_min, renderViewport.height); - - if (appearance.showGrid) { - if (appearance.showMinorGrid) { - ctx.lineWidth = 1; - ctx.strokeStyle = dark ? '#1e293b' : '#f1f5f9'; - ctx.beginPath(); - const xMinorStep = xStep / 5; - const yMinorStep = yStep / 5; - for (let x = Math.ceil(renderViewport.x_min / xMinorStep) * xMinorStep; x <= renderViewport.x_max; x += xMinorStep) { - const point = mathToPixel(renderViewport, x, 0); - ctx.moveTo(Math.round(point.x) + 0.5, 0); - ctx.lineTo(Math.round(point.x) + 0.5, renderViewport.height); - } - for (let y = Math.ceil(renderViewport.y_min / yMinorStep) * yMinorStep; y <= renderViewport.y_max; y += yMinorStep) { - const point = mathToPixel(renderViewport, 0, y); - ctx.moveTo(0, Math.round(point.y) + 0.5); - ctx.lineTo(renderViewport.width, Math.round(point.y) + 0.5); - } - ctx.stroke(); - } - - ctx.lineWidth = 1; - ctx.strokeStyle = dark ? '#334155' : '#e2e8f0'; - ctx.beginPath(); - for (let x = Math.ceil(renderViewport.x_min / xStep) * xStep; x <= renderViewport.x_max; x += xStep) { - const point = mathToPixel(renderViewport, x, 0); - ctx.moveTo(Math.round(point.x) + 0.5, 0); - ctx.lineTo(Math.round(point.x) + 0.5, renderViewport.height); - } - - for (let y = Math.ceil(renderViewport.y_min / yStep) * yStep; y <= renderViewport.y_max; y += yStep) { - const point = mathToPixel(renderViewport, 0, y); - ctx.moveTo(0, Math.round(point.y) + 0.5); - ctx.lineTo(renderViewport.width, Math.round(point.y) + 0.5); - } - ctx.stroke(); - } - - const origin = mathToPixel(renderViewport, 0, 0); - ctx.strokeStyle = dark ? '#94a3b8' : '#64748b'; - ctx.beginPath(); - if (appearance.showYAxis && origin.x >= 0 && origin.x <= renderViewport.width) { - ctx.moveTo(Math.round(origin.x) + 0.5, 0); - ctx.lineTo(Math.round(origin.x) + 0.5, renderViewport.height); - } - if (appearance.showXAxis && origin.y >= 0 && origin.y <= renderViewport.height) { - ctx.moveTo(0, Math.round(origin.y) + 0.5); - ctx.lineTo(renderViewport.width, Math.round(origin.y) + 0.5); - } - ctx.stroke(); - - if (!appearance.showAxisNumbers && !appearance.xAxisLabel && !appearance.yAxisLabel) return; - - ctx.fillStyle = dark ? '#cbd5e1' : '#64748b'; - ctx.font = '11px system-ui, sans-serif'; - const xLabelY = Math.min(renderViewport.height - 5, Math.max(13, origin.y + 14)); - const yLabelX = Math.min(renderViewport.width - 36, Math.max(4, origin.x + 5)); - - if (appearance.showAxisNumbers && appearance.showXAxis) { - for (let x = Math.ceil(renderViewport.x_min / xStep) * xStep; x <= renderViewport.x_max; x += xStep) { - if (Math.abs(x) < xStep * 1e-8) continue; - const point = mathToPixel(renderViewport, x, 0); - ctx.fillText(formatTick(x, xStep), point.x + 3, xLabelY); - } - } - - if (appearance.showAxisNumbers && appearance.showYAxis) { - for (let y = Math.ceil(renderViewport.y_min / yStep) * yStep; y <= renderViewport.y_max; y += yStep) { - if (Math.abs(y) < yStep * 1e-8) continue; - const point = mathToPixel(renderViewport, 0, y); - ctx.fillText(formatTick(y, yStep), yLabelX, point.y - 4); - } - } - - ctx.font = 'italic 13px system-ui, sans-serif'; - if (appearance.showXAxis && appearance.xAxisLabel && origin.y >= 0 && origin.y <= renderViewport.height) { - ctx.textAlign = 'right'; - ctx.fillText(appearance.xAxisLabel, renderViewport.width - 8, Math.max(14, origin.y - 7)); - } - if (appearance.showYAxis && appearance.yAxisLabel && origin.x >= 0 && origin.x <= renderViewport.width) { - ctx.textAlign = 'left'; - ctx.fillText(appearance.yAxisLabel, Math.min(renderViewport.width - 18, origin.x + 7), 16); - } - ctx.textAlign = 'start'; - } - function draw( renderViewport = presentationViewport, redrawGrid = true, @@ -763,18 +213,18 @@ grid.clearRect(0, 0, renderViewport.width, renderViewport.height); grid.fillStyle = appearance.theme === 'dark' ? '#0f172a' : '#ffffff'; grid.fillRect(0, 0, renderViewport.width, renderViewport.height); - drawGrid(grid, renderViewport); + drawGrid(grid, renderViewport, appearance); } if (redrawPlots) { const ctx = context; ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); ctx.clearRect(0, 0, renderViewport.width, renderViewport.height); - if (scene && retainedScene === scene) { + if (scene && retainedScene.scene === scene) { ctx.lineJoin = 'round'; ctx.lineCap = 'round'; - for (const retained of retainedLayers) { + for (const retained of retainedScene.layers) { const { layer, path } = retained; const plotStyle = plotAppearance(layer.expression_id); const spatialPlan = spatialPlans.get(layer.expression_id); @@ -867,7 +317,7 @@ } ctx.lineJoin = 'round'; ctx.lineCap = 'round'; - for (const retained of retainedTileMasks) { + for (const retained of retainedTiles.masks) { const ownedRegions = tileOwnership.get(retained.resultIndex); if (!ownedRegions) continue; if ( @@ -891,7 +341,7 @@ ctx.drawImage(retained.canvas, 0, 0); ctx.restore(); } - for (const retained of retainedTileLayers) { + for (const retained of retainedTiles.layers) { const ownedRegions = tileOwnership.get(retained.resultIndex); if (!ownedRegions) continue; const transform = sceneToPresentationTransform(retained.sourceViewport, renderViewport); @@ -936,7 +386,7 @@ } } const paintedAt = performance.now(); - if (redrawPlots && scene && retainedScene === scene) { + if (redrawPlots && scene && retainedScene.scene === scene) { recordScenePainted(scene, startedAt, paintedAt); } performance.clearMeasures('cartesium:canvas-draw'); @@ -979,23 +429,6 @@ }); } - function mathRectArea(rect: MathRect) { - return Math.max(0, rect.x_max - rect.x_min) * Math.max(0, rect.y_max - rect.y_min); - } - - function uncoveredMathRectArea(rect: MathRect, coverage: MathRect | undefined) { - if (!coverage) return mathRectArea(rect); - const overlapWidth = Math.max( - 0, - Math.min(rect.x_max, coverage.x_max) - Math.max(rect.x_min, coverage.x_min) - ); - const overlapHeight = Math.max( - 0, - Math.min(rect.y_max, coverage.y_max) - Math.max(rect.y_min, coverage.y_min) - ); - return Math.max(0, mathRectArea(rect) - overlapWidth * overlapHeight); - } - function handlePointerDown(event: PointerEvent) { if (event.pointerType === 'mouse' && event.button !== 0) return; if (pointers.size === 0) onviewportinteractionchange?.(true); @@ -1118,13 +551,13 @@ $effect(() => { scene; - retainScene(scene); + retainedScene.retain(scene); scheduleDraw(false, true, false); }); $effect(() => { spatialTiles; - const dirtyRegions = retainSpatialTiles(spatialTiles); + const dirtyRegions = retainedTiles.retain(spatialTiles); if (dirtyRegions.length > 0) { // Tile arrivals change the compositor's ownership map as well as the tile // layer. Redraw the plot canvas so its whole-scene fallback is clipped out diff --git a/apps/web/src/lib/graph/canvas/geometry.test.ts b/apps/web/src/lib/graph/canvas/geometry.test.ts new file mode 100644 index 0000000..7f60f2d --- /dev/null +++ b/apps/web/src/lib/graph/canvas/geometry.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import type { Viewport } from '$lib/engine/types'; + +import { + expandMathRegionsByPixels, + intersectsAnyMathRegion, + mathRectArea, + sameViewport, + sceneToPresentationTransform, + uncoveredMathRectArea +} from './geometry'; + +const viewport: Viewport = { + x_min: -5, + x_max: 5, + y_min: -4, + y_max: 4, + width: 100, + height: 80 +}; + +describe('canvas geometry', () => { + it('recognizes identical presentation viewports', () => { + expect(sameViewport(viewport, { ...viewport })).toBe(true); + expect(sameViewport(viewport, { ...viewport, x_min: -6 })).toBe(false); + }); + + it('maps a retained viewport onto the current presentation viewport', () => { + expect(sceneToPresentationTransform(viewport, viewport)).toEqual({ + xScale: 1, + yScale: 1, + xOffset: 0, + yOffset: 0 + }); + expect( + sceneToPresentationTransform(viewport, { + ...viewport, + x_min: 0, + x_max: 5 + }) + ).toEqual({ xScale: 2, yScale: 1, xOffset: -100, yOffset: 0 }); + }); + + it('expands dirty regions by a pixel-derived math-space padding and clamps them', () => { + const [edge, center] = expandMathRegionsByPixels( + [ + { x_min: -5, x_max: -4.9, y_min: 3.9, y_max: 4 }, + { x_min: -1, x_max: 1, y_min: -1, y_max: 1 } + ], + viewport, + 2 + ); + expect(edge.x_min).toBe(-5); + expect(edge.x_max).toBeCloseTo(-4.7); + expect(edge.y_min).toBeCloseTo(3.7); + expect(edge.y_max).toBe(4); + expect(center.x_min).toBeCloseTo(-1.2); + expect(center.x_max).toBeCloseTo(1.2); + expect(center.y_min).toBeCloseTo(-1.2); + expect(center.y_max).toBeCloseTo(1.2); + }); + + it('uses positive-area overlap for dirty-region intersection', () => { + const region = { x_min: 0, x_max: 1, y_min: 0, y_max: 1 }; + expect( + intersectsAnyMathRegion(region, [{ x_min: 0.5, x_max: 2, y_min: 0.5, y_max: 2 }]) + ).toBe(true); + expect( + intersectsAnyMathRegion(region, [{ x_min: 1, x_max: 2, y_min: 0, y_max: 1 }]) + ).toBe(false); + }); + + it('measures uncovered fallback area', () => { + const region = { x_min: 0, x_max: 4, y_min: 0, y_max: 3 }; + expect(mathRectArea(region)).toBe(12); + expect(uncoveredMathRectArea(region, undefined)).toBe(12); + expect( + uncoveredMathRectArea(region, { x_min: 1, x_max: 3, y_min: -1, y_max: 2 }) + ).toBe(8); + }); +}); diff --git a/apps/web/src/lib/graph/canvas/geometry.ts b/apps/web/src/lib/graph/canvas/geometry.ts new file mode 100644 index 0000000..f068602 --- /dev/null +++ b/apps/web/src/lib/graph/canvas/geometry.ts @@ -0,0 +1,293 @@ +import type { MathRect } from '$lib/engine/compositor'; +import { + clipPolygonToBounds, + clipSegmentToBounds, + type MathPoint +} from '$lib/engine/geometry-clipping'; +import type { SceneLayer, Viewport } from '$lib/engine/types'; +import type { PackedTilePolyline } from '$lib/engine/worker-protocol'; +import { mathToPixel, polylineRanges } from '$lib/graph/viewport'; + +export type RetainedLayer = + | RetainedLayerOfType<'fill'> + | RetainedLayerOfType<'points'> + | RetainedLayerOfType<'polyline'>; + +export type RetainedLayerOfType = { + type: Type; + path: Path2D; + sourceViewport: Viewport; + layer: Extract; +}; + +type PixelPoint = { x: number; y: number }; + +export function retainLayer( + layer: SceneLayer, + sourceViewport: Viewport, + clipBounds = sourceViewport +): RetainedLayer { + const path = new Path2D(); + if (layer.type === 'fill') { + for (const [xMin, xMax, yMin, yMax] of layer.rectangles ?? []) { + const clippedXMin = Math.max(xMin, clipBounds.x_min); + const clippedXMax = Math.min(xMax, clipBounds.x_max); + const clippedYMin = Math.max(yMin, clipBounds.y_min); + const clippedYMax = Math.min(yMax, clipBounds.y_max); + if (clippedXMin >= clippedXMax || clippedYMin >= clippedYMax) continue; + const topLeft = mathToPixel(sourceViewport, clippedXMin, clippedYMax); + const bottomRight = mathToPixel(sourceViewport, clippedXMax, clippedYMin); + path.rect( + topLeft.x, + topLeft.y, + bottomRight.x - topLeft.x, + bottomRight.y - topLeft.y + ); + } + for (const polygon of layer.polygons ?? []) { + const clipped = clipPolygonToBounds(polygon, clipBounds); + for (let index = 0; index < clipped.length; index += 1) { + const point = mathToPixel(sourceViewport, clipped[index][0], clipped[index][1]); + if (index === 0) path.moveTo(point.x, point.y); + else path.lineTo(point.x, point.y); + } + if (clipped.length > 0) path.closePath(); + } + return { type: 'fill', path, sourceViewport, layer }; + } + + if (layer.type === 'points') { + for (const [x, y] of layer.points) { + if ( + !Number.isFinite(x) || + !Number.isFinite(y) || + x < clipBounds.x_min || + x > clipBounds.x_max || + y < clipBounds.y_min || + y > clipBounds.y_max + ) + continue; + const point = mathToPixel(sourceViewport, x, y); + path.moveTo(point.x + 4.5, point.y); + path.arc(point.x, point.y, 4.5, 0, Math.PI * 2); + } + return { type: 'points', path, sourceViewport, layer }; + } + + for (const [start, end] of polylineRanges(layer)) { + if (end - start < 2) continue; + if (layer.source_kind === 'implicit') { + appendPresentationSmoothedRange(path, layer.vertices, start, end, sourceViewport); + continue; + } + appendClippedPolyline(path, layer.vertices, start, end, sourceViewport, clipBounds); + } + return { type: 'polyline', path, sourceViewport, layer }; +} + +export function retainPackedPolyline( + polyline: PackedTilePolyline, + sourceViewport: Viewport +): RetainedLayerOfType<'polyline'> { + const path = new Path2D(); + const vertexCount = polyline.coordinates.length / 2; + let start = 0; + for (const end of [...polyline.breaks, vertexCount]) { + if (end - start >= 2) { + if (polyline.layer.source_kind === 'implicit') { + const points: PixelPoint[] = []; + for (let index = start; index < end; index += 1) { + points.push( + mathToPixel( + sourceViewport, + polyline.coordinates[index * 2], + polyline.coordinates[index * 2 + 1] + ) + ); + } + appendPresentationSmoothedPoints(path, points); + start = end; + continue; + } + for (let index = start; index < end; index += 1) { + const point = mathToPixel( + sourceViewport, + polyline.coordinates[index * 2], + polyline.coordinates[index * 2 + 1] + ); + if (index === start) path.moveTo(point.x, point.y); + else path.lineTo(point.x, point.y); + } + } + start = end; + } + return { + type: 'polyline', + path, + sourceViewport, + layer: { ...polyline.layer, vertices: [], breaks: [] } + }; +} + +function appendClippedPolyline( + path: Path2D, + vertices: MathPoint[], + start: number, + end: number, + viewport: Viewport, + clipBounds: Viewport +) { + let previousEnd: MathPoint | undefined; + for (let index = start + 1; index < end; index += 1) { + const clipped = clipSegmentToBounds(vertices[index - 1], vertices[index], clipBounds); + if (!clipped) { + previousEnd = undefined; + continue; + } + const [segmentStart, segmentEnd] = clipped; + if (!previousEnd || !sameMathPoint(previousEnd, segmentStart)) { + const point = mathToPixel(viewport, segmentStart[0], segmentStart[1]); + path.moveTo(point.x, point.y); + } + const point = mathToPixel(viewport, segmentEnd[0], segmentEnd[1]); + path.lineTo(point.x, point.y); + previousEnd = segmentEnd; + } +} + +function sameMathPoint(left: MathPoint, right: MathPoint) { + return left[0] === right[0] && left[1] === right[1]; +} + +function appendPresentationSmoothedRange( + path: Path2D, + vertices: [number, number][], + start: number, + end: number, + viewport: Viewport +) { + const points = vertices.slice(start, end).map(([x, y]) => mathToPixel(viewport, x, y)); + appendPresentationSmoothedPoints(path, points); +} + +function appendPresentationSmoothedPoints(path: Path2D, points: PixelPoint[]) { + if (points.length < 2) return; + path.moveTo(points[0].x, points[0].y); + for (let index = 1; index < points.length - 1; index += 1) { + const previous = points[index - 1]; + const current = points[index]; + const next = points[index + 1]; + if (!isGentleTurn(previous, current, next)) { + path.lineTo(current.x, current.y); + continue; + } + path.quadraticCurveTo( + current.x, + current.y, + (current.x + next.x) / 2, + (current.y + next.y) / 2 + ); + } + const last = points[points.length - 1]; + path.lineTo(last.x, last.y); +} + +function isGentleTurn(previous: PixelPoint, current: PixelPoint, next: PixelPoint) { + const incomingX = current.x - previous.x; + const incomingY = current.y - previous.y; + const outgoingX = next.x - current.x; + const outgoingY = next.y - current.y; + const incomingLength = Math.hypot(incomingX, incomingY); + const outgoingLength = Math.hypot(outgoingX, outgoingY); + if (incomingLength === 0 || outgoingLength === 0) return false; + const cosine = + (incomingX * outgoingX + incomingY * outgoingY) / (incomingLength * outgoingLength); + // Preserve deliberate corners such as |x| + |y| = 1. Small marching-squares + // direction changes are presentation artifacts and can be rounded cheaply. + return cosine >= Math.cos(Math.PI / 6); +} + +export function sameViewport(left: Viewport, right: Viewport) { + return ( + left.x_min === right.x_min && + left.x_max === right.x_max && + left.y_min === right.y_min && + left.y_max === right.y_max && + left.width === right.width && + left.height === right.height + ); +} + +export function sceneToPresentationTransform(source: Viewport, target: Viewport) { + const sourceXSpan = source.x_max - source.x_min; + const sourceYSpan = source.y_max - source.y_min; + const targetXSpan = target.x_max - target.x_min; + const targetYSpan = target.y_max - target.y_min; + return { + xScale: (sourceXSpan / source.width) * (target.width / targetXSpan), + yScale: (sourceYSpan / source.height) * (target.height / targetYSpan), + xOffset: ((source.x_min - target.x_min) / targetXSpan) * target.width, + yOffset: ((target.y_max - source.y_max) / targetYSpan) * target.height + }; +} + +export function clipMathRegions( + ctx: CanvasRenderingContext2D, + regions: MathRect[], + sourceViewport: Viewport +) { + ctx.beginPath(); + for (const region of regions) { + const topLeft = mathToPixel(sourceViewport, region.x_min, region.y_max); + const bottomRight = mathToPixel(sourceViewport, region.x_max, region.y_min); + ctx.rect( + topLeft.x, + topLeft.y, + bottomRight.x - topLeft.x, + bottomRight.y - topLeft.y + ); + } + ctx.clip(); +} + +export function expandMathRegionsByPixels( + regions: MathRect[], + viewport: Viewport, + pixels: number +): MathRect[] { + const xPadding = ((viewport.x_max - viewport.x_min) / viewport.width) * pixels; + const yPadding = ((viewport.y_max - viewport.y_min) / viewport.height) * pixels; + return regions.map((region) => ({ + x_min: Math.max(viewport.x_min, region.x_min - xPadding), + x_max: Math.min(viewport.x_max, region.x_max + xPadding), + y_min: Math.max(viewport.y_min, region.y_min - yPadding), + y_max: Math.min(viewport.y_max, region.y_max + yPadding) + })); +} + +export function intersectsAnyMathRegion(region: MathRect, candidates: MathRect[]) { + return candidates.some( + (candidate) => + region.x_min < candidate.x_max && + candidate.x_min < region.x_max && + region.y_min < candidate.y_max && + candidate.y_min < region.y_max + ); +} + +export function mathRectArea(rect: MathRect) { + return Math.max(0, rect.x_max - rect.x_min) * Math.max(0, rect.y_max - rect.y_min); +} + +export function uncoveredMathRectArea(rect: MathRect, coverage: MathRect | undefined) { + if (!coverage) return mathRectArea(rect); + const overlapWidth = Math.max( + 0, + Math.min(rect.x_max, coverage.x_max) - Math.max(rect.x_min, coverage.x_min) + ); + const overlapHeight = Math.max( + 0, + Math.min(rect.y_max, coverage.y_max) - Math.max(rect.y_min, coverage.y_min) + ); + return Math.max(0, mathRectArea(rect) - overlapWidth * overlapHeight); +} diff --git a/apps/web/src/lib/graph/canvas/grid-renderer.test.ts b/apps/web/src/lib/graph/canvas/grid-renderer.test.ts new file mode 100644 index 0000000..b22a9bf --- /dev/null +++ b/apps/web/src/lib/graph/canvas/grid-renderer.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTick, niceGridStep } from './grid-renderer'; + +describe('grid renderer', () => { + it('chooses 1/2/5 grid steps at an approximately 80-pixel cadence', () => { + expect(niceGridStep(10, 800)).toBe(1); + expect(niceGridStep(25, 800)).toBe(5); + expect(niceGridStep(0.1, 800)).toBe(0.01); + }); + + it('formats ordinary, near-zero, and extreme tick values', () => { + expect(formatTick(0, 0.01)).toBe('0'); + expect(formatTick(1.234, 0.01)).toBe('1.23'); + expect(formatTick(100_000, 1_000)).toBe('1.0e+5'); + expect(formatTick(0.00001, 0.00001)).toBe('1.0e-5'); + }); +}); diff --git a/apps/web/src/lib/graph/canvas/grid-renderer.ts b/apps/web/src/lib/graph/canvas/grid-renderer.ts new file mode 100644 index 0000000..9d5fb6c --- /dev/null +++ b/apps/web/src/lib/graph/canvas/grid-renderer.ts @@ -0,0 +1,146 @@ +import type { Viewport } from '$lib/engine/types'; +import type { GraphAppearance } from '$lib/graph/appearance'; +import { mathToPixel } from '$lib/graph/viewport'; + +export function drawGrid( + ctx: CanvasRenderingContext2D, + renderViewport: Viewport, + appearance: GraphAppearance +) { + const dark = appearance.theme === 'dark'; + const xStep = niceGridStep(renderViewport.x_max - renderViewport.x_min, renderViewport.width); + const yStep = niceGridStep(renderViewport.y_max - renderViewport.y_min, renderViewport.height); + + if (appearance.showGrid) { + if (appearance.showMinorGrid) { + ctx.lineWidth = 1; + ctx.strokeStyle = dark ? '#1e293b' : '#f1f5f9'; + ctx.beginPath(); + const xMinorStep = xStep / 5; + const yMinorStep = yStep / 5; + for ( + let x = Math.ceil(renderViewport.x_min / xMinorStep) * xMinorStep; + x <= renderViewport.x_max; + x += xMinorStep + ) { + const point = mathToPixel(renderViewport, x, 0); + ctx.moveTo(Math.round(point.x) + 0.5, 0); + ctx.lineTo(Math.round(point.x) + 0.5, renderViewport.height); + } + for ( + let y = Math.ceil(renderViewport.y_min / yMinorStep) * yMinorStep; + y <= renderViewport.y_max; + y += yMinorStep + ) { + const point = mathToPixel(renderViewport, 0, y); + ctx.moveTo(0, Math.round(point.y) + 0.5); + ctx.lineTo(renderViewport.width, Math.round(point.y) + 0.5); + } + ctx.stroke(); + } + + ctx.lineWidth = 1; + ctx.strokeStyle = dark ? '#334155' : '#e2e8f0'; + ctx.beginPath(); + for ( + let x = Math.ceil(renderViewport.x_min / xStep) * xStep; + x <= renderViewport.x_max; + x += xStep + ) { + const point = mathToPixel(renderViewport, x, 0); + ctx.moveTo(Math.round(point.x) + 0.5, 0); + ctx.lineTo(Math.round(point.x) + 0.5, renderViewport.height); + } + + for ( + let y = Math.ceil(renderViewport.y_min / yStep) * yStep; + y <= renderViewport.y_max; + y += yStep + ) { + const point = mathToPixel(renderViewport, 0, y); + ctx.moveTo(0, Math.round(point.y) + 0.5); + ctx.lineTo(renderViewport.width, Math.round(point.y) + 0.5); + } + ctx.stroke(); + } + + const origin = mathToPixel(renderViewport, 0, 0); + ctx.strokeStyle = dark ? '#94a3b8' : '#64748b'; + ctx.beginPath(); + if (appearance.showYAxis && origin.x >= 0 && origin.x <= renderViewport.width) { + ctx.moveTo(Math.round(origin.x) + 0.5, 0); + ctx.lineTo(Math.round(origin.x) + 0.5, renderViewport.height); + } + if (appearance.showXAxis && origin.y >= 0 && origin.y <= renderViewport.height) { + ctx.moveTo(0, Math.round(origin.y) + 0.5); + ctx.lineTo(renderViewport.width, Math.round(origin.y) + 0.5); + } + ctx.stroke(); + + if (!appearance.showAxisNumbers && !appearance.xAxisLabel && !appearance.yAxisLabel) return; + + ctx.fillStyle = dark ? '#cbd5e1' : '#64748b'; + ctx.font = '11px system-ui, sans-serif'; + const xLabelY = Math.min(renderViewport.height - 5, Math.max(13, origin.y + 14)); + const yLabelX = Math.min(renderViewport.width - 36, Math.max(4, origin.x + 5)); + + if (appearance.showAxisNumbers && appearance.showXAxis) { + for ( + let x = Math.ceil(renderViewport.x_min / xStep) * xStep; + x <= renderViewport.x_max; + x += xStep + ) { + if (Math.abs(x) < xStep * 1e-8) continue; + const point = mathToPixel(renderViewport, x, 0); + ctx.fillText(formatTick(x, xStep), point.x + 3, xLabelY); + } + } + + if (appearance.showAxisNumbers && appearance.showYAxis) { + for ( + let y = Math.ceil(renderViewport.y_min / yStep) * yStep; + y <= renderViewport.y_max; + y += yStep + ) { + if (Math.abs(y) < yStep * 1e-8) continue; + const point = mathToPixel(renderViewport, 0, y); + ctx.fillText(formatTick(y, yStep), yLabelX, point.y - 4); + } + } + + ctx.font = 'italic 13px system-ui, sans-serif'; + if ( + appearance.showXAxis && + appearance.xAxisLabel && + origin.y >= 0 && + origin.y <= renderViewport.height + ) { + ctx.textAlign = 'right'; + ctx.fillText(appearance.xAxisLabel, renderViewport.width - 8, Math.max(14, origin.y - 7)); + } + if ( + appearance.showYAxis && + appearance.yAxisLabel && + origin.x >= 0 && + origin.x <= renderViewport.width + ) { + ctx.textAlign = 'left'; + ctx.fillText(appearance.yAxisLabel, Math.min(renderViewport.width - 18, origin.x + 7), 16); + } + ctx.textAlign = 'start'; +} + +export function niceGridStep(span: number, pixels: number): number { + const rough = span / Math.max(2, pixels / 80); + const power = 10 ** Math.floor(Math.log10(rough)); + const fraction = rough / power; + const multiple = fraction <= 1 ? 1 : fraction <= 2 ? 2 : fraction <= 5 ? 5 : 10; + return multiple * power; +} + +export function formatTick(value: number, step: number): string { + if (Math.abs(value) < step * 1e-8) return '0'; + if (Math.abs(value) >= 1e5 || Math.abs(value) < 1e-4) return value.toExponential(1); + const decimals = Math.max(0, -Math.floor(Math.log10(step))); + return value.toFixed(Math.min(decimals, 6)); +} diff --git a/apps/web/src/lib/graph/canvas/retained-resources.ts b/apps/web/src/lib/graph/canvas/retained-resources.ts new file mode 100644 index 0000000..b8c3aa3 --- /dev/null +++ b/apps/web/src/lib/graph/canvas/retained-resources.ts @@ -0,0 +1,205 @@ +import { writeMaskRgba } from '$lib/engine/mask-rgba'; +import type { MathRect } from '$lib/engine/compositor'; +import { recordSceneRetention } from '$lib/engine/scene-telemetry'; +import type { RenderScene2d, Viewport } from '$lib/engine/types'; +import type { SpatialTileResult } from '$lib/engine/worker-protocol'; +import { recordTileRetention } from '$lib/engine/tile-telemetry'; +import { spatialResourceKey, tileCoverage } from '$lib/engine/tiles'; + +import { + retainLayer, + retainPackedPolyline, + type RetainedLayer +} from './geometry'; + +const MAX_RETAINED_PATHS = 64; +const MAX_RETAINED_TILE_RESOURCES = 128; + +export type RetainedTileLayer = RetainedLayer & { + resource: SpatialTileResult['resource']; + resultIndex: number; +}; + +export interface RetainedTileMask { + canvas: HTMLCanvasElement; + sourceViewport: Viewport; + resource: SpatialTileResult['resource']; + resultIndex: number; +} + +type CachedTileLayer = RetainedLayer & { resource: SpatialTileResult['resource'] }; +type CachedTileMask = Omit; +type CachedTileResource = { masks: CachedTileMask[]; layers: CachedTileLayer[] }; + +export class RetainedSceneResources { + scene: RenderScene2d | null = null; + layers: RetainedLayer[] = []; + + readonly #pathCache = new Map(); + + retain(nextScene: RenderScene2d | null): void { + const startedAt = performance.now(); + let compiledPathCount = 0; + this.scene = nextScene; + this.layers = nextScene + ? nextScene.layers.map((layer) => { + const cached = this.#pathCache.get(layer.geometry_id); + if (cached) { + this.#pathCache.delete(layer.geometry_id); + this.#pathCache.set(layer.geometry_id, cached); + return { type: layer.type, layer, ...cached } as RetainedLayer; + } + compiledPathCount += 1; + const retained = retainLayer(layer, nextScene.viewport, nextScene.coverage); + this.#pathCache.set(layer.geometry_id, { + path: retained.path, + sourceViewport: retained.sourceViewport + }); + return retained; + }) + : []; + trimOldest(this.#pathCache, MAX_RETAINED_PATHS); + + const retainedAt = performance.now(); + if (nextScene) recordSceneRetention(nextScene, startedAt, retainedAt); + performance.clearMeasures('cartesium:scene-retain'); + performance.measure('cartesium:scene-retain', { + start: 0, + duration: retainedAt - startedAt, + detail: { + layerCount: this.layers.length, + compiledPathCount, + reusedPathCount: this.layers.length - compiledPathCount, + cachedPathCount: this.#pathCache.size + } + }); + } +} + +export class RetainedTileResources { + masks: RetainedTileMask[] = []; + layers: RetainedTileLayer[] = []; + results: SpatialTileResult[] = []; + + readonly #resourceCache = new Map(); + + retain(tiles: SpatialTileResult[]): MathRect[] { + const startedAt = performance.now(); + let createdCount = 0; + let reusedCount = 0; + const completeResourceKeys = new Set( + tiles.filter(({ complete }) => complete).map(({ resource }) => spatialResourceKey(resource)) + ); + for (const key of this.#resourceCache.keys()) { + const separator = key.lastIndexOf(':'); + const baseKey = separator === -1 ? key : key.slice(0, separator); + if (key.endsWith(':complete') || !completeResourceKeys.has(baseKey)) continue; + this.#resourceCache.delete(key); + } + + const retained = tiles.map((tile) => { + const key = retainedTileResultKey(tile); + const cached = this.#resourceCache.get(key); + if (cached) { + reusedCount += 1; + this.#resourceCache.delete(key); + this.#resourceCache.set(key, cached); + return cached; + } + createdCount += 1; + const created = retainSpatialTile(tile); + this.#resourceCache.set(key, created); + return created; + }); + trimOldest(this.#resourceCache, MAX_RETAINED_TILE_RESOURCES); + + this.masks = retained.flatMap(({ masks }, resultIndex) => + masks.map((mask) => ({ ...mask, resultIndex })) + ); + this.layers = retained.flatMap(({ layers }, resultIndex) => + layers.map((layer) => ({ ...layer, resultIndex })) + ); + + const retainedAt = performance.now(); + for (const tile of tiles) recordTileRetention(tile, retainedAt); + performance.clearMeasures('cartesium:tile-retain'); + performance.measure('cartesium:tile-retain', { + start: 0, + duration: performance.now() - startedAt, + detail: { + resourceCount: tiles.length, + createdCount, + reusedCount, + cachedResourceCount: this.#resourceCache.size + } + }); + + const previous = new Map(this.results.map((tile) => [retainedTileResultKey(tile), tile])); + const next = new Map(tiles.map((tile) => [retainedTileResultKey(tile), tile])); + const dirtyRegions = [ + ...this.results + .filter((tile) => !next.has(retainedTileResultKey(tile))) + .map(spatialTilePresentableCoverage), + ...tiles + .filter((tile) => !previous.has(retainedTileResultKey(tile))) + .map(spatialTilePresentableCoverage) + ]; + this.results = tiles; + return dirtyRegions; + } +} + +function retainSpatialTile(tile: SpatialTileResult): CachedTileResource { + const masks = (() => { + if (!tile.mask) return []; + const backing = document.createElement('canvas'); + backing.width = tile.mask.width; + backing.height = tile.mask.height; + const backingContext = backing.getContext('2d'); + if (!backingContext) return []; + const image = backingContext.createImageData(tile.mask.width, tile.mask.height); + writeMaskRgba(image.data, tile.mask.alpha, parseHexColor(tile.mask.color)); + backingContext.putImageData(image, 0, 0); + return [{ canvas: backing, sourceViewport: tile.scene.viewport, resource: tile.resource }]; + })(); + const layers = [ + ...tile.scene.layers + .filter( + (layer) => + tile.resource.representation === 'inequality-mask' || + (layer.type === 'polyline' && layer.source_kind === 'implicit') + ) + .map((layer) => ({ + ...retainLayer(layer, tile.scene.viewport), + resource: tile.resource + })), + ...(tile.polylines ?? []).map((polyline) => ({ + ...retainPackedPolyline(polyline, tile.scene.viewport), + resource: tile.resource + })) + ]; + return { masks, layers }; +} + +function spatialTilePresentableCoverage(tile: SpatialTileResult): MathRect { + return tile.coverage ?? tileCoverage(tile.resource, 0); +} + +function retainedTileResultKey(tile: SpatialTileResult) { + return `${spatialResourceKey(tile.resource)}:${tile.complete ? 'complete' : `part-${tile.partIndex ?? 0}`}`; +} + +function parseHexColor(color: string): [number, number, number] { + const match = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color); + return match + ? [Number.parseInt(match[1], 16), Number.parseInt(match[2], 16), Number.parseInt(match[3], 16)] + : [37, 99, 235]; +} + +function trimOldest(cache: Map, maximumSize: number): void { + while (cache.size > maximumSize) { + const oldest = cache.keys().next().value; + if (oldest === undefined) break; + cache.delete(oldest); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 97a5f72..f721ca7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -309,11 +309,12 @@ state if they still match current demand. ### 6. GraphCanvas retains incoming output -`GraphCanvas` converts scene layers and tile results into drawable resources. -Retention is keyed so unchanged resources can be reused rather than rebuilt. -Before constructing a `Path2D`, whole-scene geometry is clipped in math space -to the scene's valid coverage. This is a required numerical boundary, not only -an optimization: a complete polar polygon viewed +`GraphCanvas` delegates scene and tile conversion to the retained-resource +stores under `apps/web/src/lib/graph/canvas/`. Retention is keyed so unchanged +resources can be reused rather than rebuilt. Before constructing a `Path2D`, +whole-scene geometry is clipped in math space to the scene's valid coverage. +This is a required numerical boundary, not only an optimization: a complete +polar polygon viewed at extreme zoom would otherwise turn ordinary offscreen coordinates into huge Canvas path coordinates and leave clipping to the browser rasterizer. @@ -557,7 +558,9 @@ replace them incrementally without double painting. - `apps/web/src/lib/engine/tile-presentation.ts` defines compatibility retention and frame-batched result merging. - `apps/web/src/lib/components/GraphCanvas.svelte` owns the presentation camera, - retained browser resources, the three canvases, and draw invalidation. + the three canvases, and draw invalidation. +- `apps/web/src/lib/graph/canvas/` owns retained browser resources, Canvas path + construction and clipping, viewport transforms, and grid rendering policy. - `apps/web/src/lib/engine/compositor.ts` computes disjoint fallback and tile ownership regions. - `apps/web/src/lib/engine/tiles.ts` defines world tile levels, coordinates, diff --git a/docs/render-pipeline.md b/docs/render-pipeline.md index bec3f30..4ed5d41 100644 --- a/docs/render-pipeline.md +++ b/docs/render-pipeline.md @@ -70,8 +70,9 @@ The relevant implementation files are: - `crates/cartesium-core/src/sampling.rs`: samples explicit, parametric, polar, implicit, and inequality expressions. - `apps/web/src/lib/components/GraphCanvas.svelte`: owns the presentation camera, - separate grid/plot/tile canvases, retained Canvas resources, dirty tile - regions, and animation-frame draw coalescing. + separate grid/plot/tile canvases, dirty tile regions, and animation-frame draw + coalescing. Plain TypeScript modules under `apps/web/src/lib/graph/canvas/` + own retained Canvas resources, geometry transforms, clipping, and grid drawing. ## Scheduling and stale work