@canvas-harness/core 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -2097,7 +2097,13 @@ type CanvasSurface = {
2097
2097
  /** Device pixels — backing-store size. */
2098
2098
  dpr: number;
2099
2099
  };
2100
- declare const getDpr: () => number;
2100
+ /**
2101
+ * Resolved DPR for the canvas backing store. Clamped by `maxDpr`
2102
+ * (consumer-supplied) and the absolute `HARD_MAX_DPR` ceiling. When
2103
+ * `maxDpr` is omitted, the tier-based default (see
2104
+ * `defaultMaxDprForSize`) is used — pass `cssW`/`cssH` to enable it.
2105
+ */
2106
+ declare const getDpr: (maxDpr?: number, cssW?: number, cssH?: number) => number;
2101
2107
  /**
2102
2108
  * Builds a managed canvas surface. Caller pins the canvas element; we size
2103
2109
  * it and reset the 2d context's transform to logical-pixel space.
@@ -2105,12 +2111,12 @@ declare const getDpr: () => number;
2105
2111
  * Subsequent calls to `setSize` re-allocate the backing store if the new
2106
2112
  * `cssW × cssH × DPR` differs from the current.
2107
2113
  */
2108
- declare const setupSurface: (canvas: HTMLCanvasElement) => CanvasSurface;
2114
+ declare const setupSurface: (canvas: HTMLCanvasElement, _maxDpr?: number) => CanvasSurface;
2109
2115
  /**
2110
2116
  * Resizes the surface to a new CSS-pixel size, picking up the current DPR.
2111
2117
  * Returns true if anything changed (caller should redraw).
2112
2118
  */
2113
- declare const sizeSurface: (surface: CanvasSurface, cssW: number, cssH: number) => boolean;
2119
+ declare const sizeSurface: (surface: CanvasSurface, cssW: number, cssH: number, maxDpr?: number) => boolean;
2114
2120
  /**
2115
2121
  * Clears the entire backing store. Call before setting the camera transform.
2116
2122
  */
@@ -2202,6 +2208,18 @@ type RendererOptions = {
2202
2208
  * fill tints via globalAlpha — no parsing needed.
2203
2209
  */
2204
2210
  selectionColor?: string;
2211
+ /**
2212
+ * Cap on the canvas backing-store DPR (device-pixel ratio). At
2213
+ * native DPR on hi-DPI displays, the backing buffer can hit
2214
+ * 20-30 megapixels per frame; the GPU-upload step alone dominates
2215
+ * the frame budget. Defaults to `1` for consistent perf across
2216
+ * hardware. Bump to `2` (or `window.devicePixelRatio`) for
2217
+ * pixel-crisp rendering at the cost of FPS on hi-DPI displays.
2218
+ *
2219
+ * Text is unaffected — the text bitmap cache renders glyphs at
2220
+ * its own DPR-aware scale.
2221
+ */
2222
+ maxDpr?: number;
2205
2223
  /**
2206
2224
  * Fires when the set of custom nodes that should be rendered in the DOM
2207
2225
  * overlay changes. Consumers use this to mount/unmount React subtrees
package/dist/index.d.ts CHANGED
@@ -2097,7 +2097,13 @@ type CanvasSurface = {
2097
2097
  /** Device pixels — backing-store size. */
2098
2098
  dpr: number;
2099
2099
  };
2100
- declare const getDpr: () => number;
2100
+ /**
2101
+ * Resolved DPR for the canvas backing store. Clamped by `maxDpr`
2102
+ * (consumer-supplied) and the absolute `HARD_MAX_DPR` ceiling. When
2103
+ * `maxDpr` is omitted, the tier-based default (see
2104
+ * `defaultMaxDprForSize`) is used — pass `cssW`/`cssH` to enable it.
2105
+ */
2106
+ declare const getDpr: (maxDpr?: number, cssW?: number, cssH?: number) => number;
2101
2107
  /**
2102
2108
  * Builds a managed canvas surface. Caller pins the canvas element; we size
2103
2109
  * it and reset the 2d context's transform to logical-pixel space.
@@ -2105,12 +2111,12 @@ declare const getDpr: () => number;
2105
2111
  * Subsequent calls to `setSize` re-allocate the backing store if the new
2106
2112
  * `cssW × cssH × DPR` differs from the current.
2107
2113
  */
2108
- declare const setupSurface: (canvas: HTMLCanvasElement) => CanvasSurface;
2114
+ declare const setupSurface: (canvas: HTMLCanvasElement, _maxDpr?: number) => CanvasSurface;
2109
2115
  /**
2110
2116
  * Resizes the surface to a new CSS-pixel size, picking up the current DPR.
2111
2117
  * Returns true if anything changed (caller should redraw).
2112
2118
  */
2113
- declare const sizeSurface: (surface: CanvasSurface, cssW: number, cssH: number) => boolean;
2119
+ declare const sizeSurface: (surface: CanvasSurface, cssW: number, cssH: number, maxDpr?: number) => boolean;
2114
2120
  /**
2115
2121
  * Clears the entire backing store. Call before setting the camera transform.
2116
2122
  */
@@ -2202,6 +2208,18 @@ type RendererOptions = {
2202
2208
  * fill tints via globalAlpha — no parsing needed.
2203
2209
  */
2204
2210
  selectionColor?: string;
2211
+ /**
2212
+ * Cap on the canvas backing-store DPR (device-pixel ratio). At
2213
+ * native DPR on hi-DPI displays, the backing buffer can hit
2214
+ * 20-30 megapixels per frame; the GPU-upload step alone dominates
2215
+ * the frame budget. Defaults to `1` for consistent perf across
2216
+ * hardware. Bump to `2` (or `window.devicePixelRatio`) for
2217
+ * pixel-crisp rendering at the cost of FPS on hi-DPI displays.
2218
+ *
2219
+ * Text is unaffected — the text bitmap cache renders glyphs at
2220
+ * its own DPR-aware scale.
2221
+ */
2222
+ maxDpr?: number;
2205
2223
  /**
2206
2224
  * Fires when the set of custom nodes that should be rendered in the DOM
2207
2225
  * overlay changes. Consumers use this to mount/unmount React subtrees
package/dist/index.js CHANGED
@@ -855,23 +855,12 @@ var darkenHex = (hex) => {
855
855
 
856
856
  // src/render/shapes/path-helpers.ts
857
857
  var buildRectPath = (ctx, w, h, radius) => {
858
+ ctx.beginPath();
858
859
  if (radius <= 0) {
859
- ctx.beginPath();
860
860
  ctx.rect(0, 0, w, h);
861
861
  return;
862
862
  }
863
- const r = Math.min(radius, w / 2, h / 2);
864
- ctx.beginPath();
865
- ctx.moveTo(r, 0);
866
- ctx.lineTo(w - r, 0);
867
- ctx.quadraticCurveTo(w, 0, w, r);
868
- ctx.lineTo(w, h - r);
869
- ctx.quadraticCurveTo(w, h, w - r, h);
870
- ctx.lineTo(r, h);
871
- ctx.quadraticCurveTo(0, h, 0, h - r);
872
- ctx.lineTo(0, r);
873
- ctx.quadraticCurveTo(0, 0, r, 0);
874
- ctx.closePath();
863
+ ctx.roundRect(0, 0, w, h, radius);
875
864
  };
876
865
  var buildEllipsePath = (ctx, w, h) => {
877
866
  const rx = w / 2;
@@ -4253,13 +4242,21 @@ var storeToJSON = (store) => ({
4253
4242
  });
4254
4243
 
4255
4244
  // src/render/canvas-setup.ts
4256
- var MAX_DPR = 3;
4257
- var getDpr = () => {
4245
+ var HARD_MAX_DPR = 3;
4246
+ var defaultMaxDprForSize = (cssW, cssH) => {
4247
+ const cssPx = cssW * cssH;
4248
+ if (cssPx >= 25e5) return 1;
4249
+ if (cssPx >= 15e5) return 1.5;
4250
+ return 2;
4251
+ };
4252
+ var getDpr = (maxDpr, cssW = 0, cssH = 0) => {
4258
4253
  if (typeof window === "undefined") return 1;
4259
4254
  const raw = window.devicePixelRatio || 1;
4260
- return Math.max(1, Math.min(MAX_DPR, raw));
4255
+ const resolvedMax = maxDpr === void 0 && cssW > 0 && cssH > 0 ? defaultMaxDprForSize(cssW, cssH) : maxDpr ?? 1;
4256
+ const cap = Math.max(1, Math.min(HARD_MAX_DPR, resolvedMax));
4257
+ return Math.max(1, Math.min(cap, raw));
4261
4258
  };
4262
- var setupSurface = (canvas) => {
4259
+ var setupSurface = (canvas, _maxDpr) => {
4263
4260
  const ctx = canvas.getContext("2d");
4264
4261
  if (!ctx) throw new Error("Canvas 2d context unavailable");
4265
4262
  return {
@@ -4267,11 +4264,12 @@ var setupSurface = (canvas) => {
4267
4264
  ctx,
4268
4265
  cssWidth: 0,
4269
4266
  cssHeight: 0,
4270
- dpr: getDpr()
4267
+ dpr: 1
4268
+ // placeholder; `sizeSurface` writes the real value
4271
4269
  };
4272
4270
  };
4273
- var sizeSurface = (surface, cssW, cssH) => {
4274
- const dpr = getDpr();
4271
+ var sizeSurface = (surface, cssW, cssH, maxDpr) => {
4272
+ const dpr = getDpr(maxDpr, cssW, cssH);
4275
4273
  if (surface.cssWidth === cssW && surface.cssHeight === cssH && surface.dpr === dpr) {
4276
4274
  return false;
4277
4275
  }
@@ -4886,22 +4884,47 @@ var drawWithNodeTransform = (ctx, node, fn) => {
4886
4884
  };
4887
4885
 
4888
4886
  // src/render/renderer.ts
4889
- var VIEWPORT_OVERSCAN_PX = 64;
4887
+ var SCENE_CACHE_MARGIN_PX = 256;
4890
4888
  var MIN_ON_SCREEN_SIZE_PX = 1.5;
4891
4889
  var MIN_READABLE_FONT_PX = 3;
4892
4890
  var createRenderer = (opts) => {
4893
4891
  const { store, theme, onOverlayChange } = opts;
4892
+ const maxDpr = opts.maxDpr;
4894
4893
  const staticSurface = setupSurface(opts.staticCanvas);
4895
4894
  const interactiveSurface = setupSurface(opts.interactiveCanvas);
4896
4895
  let background = opts.background;
4897
4896
  let selectionColor = opts.selectionColor ?? DEFAULT_SELECTION_COLOR;
4898
4897
  let hideFrames = false;
4899
- sizeSurface(staticSurface, opts.width, opts.height);
4900
- sizeSurface(interactiveSurface, opts.width, opts.height);
4898
+ sizeSurface(staticSurface, opts.width, opts.height, maxDpr);
4899
+ sizeSurface(interactiveSurface, opts.width, opts.height, maxDpr);
4901
4900
  let staticDirty = true;
4902
4901
  let interactiveDirty = false;
4903
4902
  let overlaySet = /* @__PURE__ */ new Set();
4904
4903
  let lastDrawn = 0;
4904
+ let cacheSurface = null;
4905
+ let cacheCamX = 0;
4906
+ let cacheCamY = 0;
4907
+ let cacheCamZ = 1;
4908
+ let cacheStale = true;
4909
+ const ensureCacheSurface = () => {
4910
+ const dpr = staticSurface.dpr;
4911
+ const cssW = staticSurface.cssWidth + 2 * SCENE_CACHE_MARGIN_PX;
4912
+ const cssH = staticSurface.cssHeight + 2 * SCENE_CACHE_MARGIN_PX;
4913
+ if (!cacheSurface) {
4914
+ const canvas = document.createElement("canvas");
4915
+ const ctx = canvas.getContext("2d");
4916
+ if (!ctx) throw new Error("Canvas 2d context unavailable");
4917
+ cacheSurface = { canvas, ctx, cssWidth: 0, cssHeight: 0, dpr: 1 };
4918
+ }
4919
+ if (cacheSurface.cssWidth !== cssW || cacheSurface.cssHeight !== cssH || cacheSurface.dpr !== dpr) {
4920
+ cacheSurface.cssWidth = cssW;
4921
+ cacheSurface.cssHeight = cssH;
4922
+ cacheSurface.dpr = dpr;
4923
+ cacheSurface.canvas.width = Math.max(1, Math.round(cssW * dpr));
4924
+ cacheSurface.canvas.height = Math.max(1, Math.round(cssH * dpr));
4925
+ }
4926
+ return cacheSurface;
4927
+ };
4905
4928
  let sortedNodeIdsCache = null;
4906
4929
  let sortedEdgeIdsCache = null;
4907
4930
  const invalidateSortedCaches = () => {
@@ -4910,6 +4933,7 @@ var createRenderer = (opts) => {
4910
4933
  };
4911
4934
  const requestRepaint = () => {
4912
4935
  staticDirty = true;
4936
+ cacheStale = true;
4913
4937
  loop.requestFrame();
4914
4938
  };
4915
4939
  const assetCache = createAssetCache({ onReady: requestRepaint });
@@ -4924,16 +4948,12 @@ var createRenderer = (opts) => {
4924
4948
  interactiveDirty = false;
4925
4949
  }
4926
4950
  };
4927
- const paintStatic = () => {
4928
- const camera = store.getCamera();
4929
- clearSurface(staticSurface);
4930
- applyCameraTransform(staticSurface, camera);
4931
- const scale = camera.z * staticSurface.dpr;
4951
+ const paintSceneBody = (surface, camera, viewport, fullRender = true) => {
4952
+ const scale = camera.z * surface.dpr;
4932
4953
  const interaction = store.getInteractionState();
4933
4954
  const excludedNodes = interaction.mode === "dragging" || interaction.mode === "resizing" ? new Set(interaction.draggedIds) : null;
4934
4955
  const excludedEdges = excludedNodes ? incidentEdgeIds(excludedNodes) : null;
4935
- const viewport = inflateRect(worldViewport(staticSurface, camera), VIEWPORT_OVERSCAN_PX);
4936
- paintBackground(staticSurface.ctx, { viewport, zoom: camera.z, background });
4956
+ paintBackground(surface.ctx, { viewport, zoom: camera.z, background });
4937
4957
  const visible = visibleNodes(camera, viewport);
4938
4958
  const isMoving2 = interaction.mode === "panning" || interaction.mode === "zooming" || interaction.mode === "dragging" || interaction.mode === "resizing" || interaction.mode === "rotating";
4939
4959
  const minOnScreen = MIN_ON_SCREEN_SIZE_PX;
@@ -4955,8 +4975,8 @@ var createRenderer = (opts) => {
4955
4975
  for (const node of visible) {
4956
4976
  if (node.type !== "frame") continue;
4957
4977
  if (excludedNodes?.has(node.id)) continue;
4958
- drawWithNodeTransform(staticSurface.ctx, node, () => {
4959
- paintFrameNode(staticSurface.ctx, node, scale, theme);
4978
+ drawWithNodeTransform(surface.ctx, node, () => {
4979
+ paintFrameNode(surface.ctx, node, scale, theme);
4960
4980
  });
4961
4981
  drawn++;
4962
4982
  }
@@ -4969,52 +4989,53 @@ var createRenderer = (opts) => {
4969
4989
  const useRough = roughEnabled && (node.style?.roughness ?? 0) > 0;
4970
4990
  const roughReady = useRough ? getRoughCanvasCtor() !== null : false;
4971
4991
  const composite = isCompositePrimitive(node.type);
4972
- drawWithNodeTransform(staticSurface.ctx, node, () => {
4992
+ drawWithNodeTransform(surface.ctx, node, () => {
4973
4993
  if (useRough && roughReady) {
4974
4994
  if (composite) {
4975
- drawCompositeRough(staticSurface.ctx, node, camera.z, theme);
4995
+ drawCompositeRough(surface.ctx, node, camera.z, theme);
4976
4996
  } else {
4977
- staticSurface.ctx.translate(ROUGH_FILL_MISREGISTER_X, ROUGH_FILL_MISREGISTER_Y);
4978
- drawShape(staticSurface.ctx, node, scale, theme, { skipStroke: true });
4979
- staticSurface.ctx.translate(-ROUGH_FILL_MISREGISTER_X, -ROUGH_FILL_MISREGISTER_Y);
4980
- drawRoughShape(staticSurface.ctx, node, camera.z, theme);
4997
+ surface.ctx.translate(ROUGH_FILL_MISREGISTER_X, ROUGH_FILL_MISREGISTER_Y);
4998
+ drawShape(surface.ctx, node, scale, theme, { skipStroke: true });
4999
+ surface.ctx.translate(-ROUGH_FILL_MISREGISTER_X, -ROUGH_FILL_MISREGISTER_Y);
5000
+ drawRoughShape(surface.ctx, node, camera.z, theme);
4981
5001
  }
4982
5002
  } else {
4983
- drawShape(staticSurface.ctx, node, scale, theme);
5003
+ drawShape(surface.ctx, node, scale, theme);
4984
5004
  if (useRough && !roughReady) {
4985
5005
  onRoughReady(() => {
4986
5006
  staticDirty = true;
5007
+ cacheStale = true;
4987
5008
  loop.requestFrame();
4988
5009
  });
4989
5010
  }
4990
5011
  }
4991
- if (!isEditingThis) paintNodeContent(staticSurface.ctx, node, renderEnv);
5012
+ if (!isEditingThis) paintNodeContent(surface.ctx, node, renderEnv);
4992
5013
  });
4993
5014
  drawn++;
4994
5015
  continue;
4995
5016
  }
4996
5017
  if (node.type === "image") {
4997
- drawWithNodeTransform(staticSurface.ctx, node, () => {
4998
- paintImageNode(staticSurface.ctx, node, assetCache, theme);
5018
+ drawWithNodeTransform(surface.ctx, node, () => {
5019
+ paintImageNode(surface.ctx, node, assetCache, theme);
4999
5020
  });
5000
5021
  drawn++;
5001
5022
  continue;
5002
5023
  }
5003
5024
  if (node.type === "icon") {
5004
- drawWithNodeTransform(staticSurface.ctx, node, () => {
5005
- paintIconNode(staticSurface.ctx, node, assetCache, scale, theme);
5025
+ drawWithNodeTransform(surface.ctx, node, () => {
5026
+ paintIconNode(surface.ctx, node, assetCache, scale, theme);
5006
5027
  });
5007
5028
  drawn++;
5008
5029
  continue;
5009
5030
  }
5010
5031
  if (node.type === "text") {
5011
- drawWithNodeTransform(staticSurface.ctx, node, () => {
5032
+ drawWithNodeTransform(surface.ctx, node, () => {
5012
5033
  if (isEditingThis) return;
5013
5034
  const hasContent = node.content && node.content.trim().length > 0;
5014
5035
  if (hasContent) {
5015
- paintNodeContent(staticSurface.ctx, node, renderEnv);
5036
+ paintNodeContent(surface.ctx, node, renderEnv);
5016
5037
  } else {
5017
- paintEmptyTextPlaceholder(staticSurface.ctx, node, camera.z);
5038
+ paintEmptyTextPlaceholder(surface.ctx, node, camera.z);
5018
5039
  }
5019
5040
  });
5020
5041
  drawn++;
@@ -5026,7 +5047,7 @@ var createRenderer = (opts) => {
5026
5047
  if (camera.z < def.lod.minZoomForPlaceholder) continue;
5027
5048
  const preferCanvas = camera.z < def.lod.minZoomForReact || isMoving2;
5028
5049
  if (preferCanvas) {
5029
- if (paintCustomCanvasFallback(staticSurface.ctx, node, def, scale, renderEnv)) {
5050
+ if (paintCustomCanvasFallback(surface.ctx, node, def, scale, renderEnv)) {
5030
5051
  drawn++;
5031
5052
  }
5032
5053
  continue;
@@ -5036,10 +5057,10 @@ var createRenderer = (opts) => {
5036
5057
  continue;
5037
5058
  }
5038
5059
  if (def.renderCanvas) {
5039
- drawWithNodeTransform(staticSurface.ctx, node, () => {
5040
- staticSurface.ctx.save();
5041
- def.renderCanvas(staticSurface.ctx, node, renderEnv);
5042
- staticSurface.ctx.restore();
5060
+ drawWithNodeTransform(surface.ctx, node, () => {
5061
+ surface.ctx.save();
5062
+ def.renderCanvas(surface.ctx, node, renderEnv);
5063
+ surface.ctx.restore();
5043
5064
  });
5044
5065
  drawn++;
5045
5066
  }
@@ -5048,15 +5069,120 @@ var createRenderer = (opts) => {
5048
5069
  const edgeRoughEnabled = !cameraIsMoving && movingNodeCount <= ROUGH_MAX_MOVING_NODES && camera.z >= ROUGH_MIN_ZOOM && visEdges.length <= ROUGH_MAX_NODES;
5049
5070
  for (const edge of visEdges) {
5050
5071
  if (excludedEdges?.has(edge.id)) continue;
5051
- paintOneEdge(staticSurface.ctx, edge, scale, edgeRoughEnabled, camera.z, isMoving2);
5072
+ paintOneEdge(surface.ctx, edge, scale, edgeRoughEnabled, camera.z, isMoving2);
5052
5073
  drawn++;
5053
5074
  }
5075
+ if (!fullRender) return;
5054
5076
  lastDrawn = drawn;
5055
5077
  if (!setsEqual(nextOverlaySet, overlaySet)) {
5056
5078
  overlaySet = nextOverlaySet;
5057
5079
  onOverlayChange?.([...overlaySet]);
5058
5080
  }
5059
5081
  };
5082
+ const applyCacheTransform = (cache5, centerX, centerY, z) => {
5083
+ const s = z * cache5.dpr;
5084
+ const m = SCENE_CACHE_MARGIN_PX * cache5.dpr;
5085
+ cache5.ctx.setTransform(s, 0, 0, s, -centerX * s + m, -centerY * s + m);
5086
+ };
5087
+ const renderFullCache = (camera) => {
5088
+ const cache5 = ensureCacheSurface();
5089
+ clearSurface(cache5);
5090
+ applyCacheTransform(cache5, camera.x, camera.y, camera.z);
5091
+ const marginWorld = SCENE_CACHE_MARGIN_PX / camera.z;
5092
+ const viewport = inflateRect(worldViewport(staticSurface, camera), marginWorld);
5093
+ paintSceneBody(cache5, camera, viewport);
5094
+ cacheCamX = camera.x;
5095
+ cacheCamY = camera.y;
5096
+ cacheCamZ = camera.z;
5097
+ cacheStale = false;
5098
+ };
5099
+ const canExtend = (camera) => {
5100
+ if (!cacheSurface) return false;
5101
+ const s = camera.z * staticSurface.dpr;
5102
+ const dx = Math.abs((cacheCamX - camera.x) * s);
5103
+ const dy = Math.abs((cacheCamY - camera.y) * s);
5104
+ return dx < cacheSurface.canvas.width && dy < cacheSurface.canvas.height;
5105
+ };
5106
+ const renderCacheStrip = (cache5, centerX, centerY, z, px, py, pw, ph) => {
5107
+ const ctx = cache5.ctx;
5108
+ const s = z * cache5.dpr;
5109
+ const m = SCENE_CACHE_MARGIN_PX * cache5.dpr;
5110
+ ctx.save();
5111
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
5112
+ ctx.beginPath();
5113
+ ctx.rect(px, py, pw, ph);
5114
+ ctx.clip();
5115
+ ctx.clearRect(px, py, pw, ph);
5116
+ applyCacheTransform(cache5, centerX, centerY, z);
5117
+ const viewport = {
5118
+ x: (px - m) / s + centerX,
5119
+ y: (py - m) / s + centerY,
5120
+ w: pw / s,
5121
+ h: ph / s
5122
+ };
5123
+ paintSceneBody(cache5, { z }, viewport, false);
5124
+ ctx.restore();
5125
+ };
5126
+ const extendCache = (camera) => {
5127
+ const cache5 = ensureCacheSurface();
5128
+ const s = camera.z * cache5.dpr;
5129
+ const cacheW = cache5.canvas.width;
5130
+ const cacheH = cache5.canvas.height;
5131
+ const dx = Math.round((cacheCamX - camera.x) * s);
5132
+ const dy = Math.round((cacheCamY - camera.y) * s);
5133
+ const newCamX = cacheCamX - dx / s;
5134
+ const newCamY = cacheCamY - dy / s;
5135
+ cache5.ctx.setTransform(1, 0, 0, 1, 0, 0);
5136
+ cache5.ctx.drawImage(cache5.canvas, 0, 0, cacheW, cacheH, dx, dy, cacheW, cacheH);
5137
+ cacheCamX = newCamX;
5138
+ cacheCamY = newCamY;
5139
+ cacheCamZ = camera.z;
5140
+ const hw = Math.abs(dx);
5141
+ const vh = Math.abs(dy);
5142
+ const hx = dx > 0 ? 0 : cacheW - hw;
5143
+ const vy = dy > 0 ? 0 : cacheH - vh;
5144
+ const vx = dx > 0 ? hw : 0;
5145
+ const vw = cacheW - hw;
5146
+ if (hw > 0) renderCacheStrip(cache5, newCamX, newCamY, camera.z, hx, 0, hw, cacheH);
5147
+ if (vh > 0 && vw > 0) renderCacheStrip(cache5, newCamX, newCamY, camera.z, vx, vy, vw, vh);
5148
+ };
5149
+ const cacheSourceOffset = (camera) => {
5150
+ const dpr = staticSurface.dpr;
5151
+ return {
5152
+ x: Math.round(((camera.x - cacheCamX) * cacheCamZ + SCENE_CACHE_MARGIN_PX) * dpr),
5153
+ y: Math.round(((camera.y - cacheCamY) * cacheCamZ + SCENE_CACHE_MARGIN_PX) * dpr)
5154
+ };
5155
+ };
5156
+ const viewportFitsInCache = (camera) => {
5157
+ if (!cacheSurface) return false;
5158
+ const { x, y } = cacheSourceOffset(camera);
5159
+ return x >= 0 && y >= 0 && x + staticSurface.canvas.width <= cacheSurface.canvas.width && y + staticSurface.canvas.height <= cacheSurface.canvas.height;
5160
+ };
5161
+ const presentStatic = (camera) => {
5162
+ const cache5 = ensureCacheSurface();
5163
+ const w = staticSurface.canvas.width;
5164
+ const h = staticSurface.canvas.height;
5165
+ const { x: srcX, y: srcY } = cacheSourceOffset(camera);
5166
+ staticSurface.ctx.setTransform(1, 0, 0, 1, 0, 0);
5167
+ staticSurface.ctx.clearRect(0, 0, w, h);
5168
+ staticSurface.ctx.drawImage(cache5.canvas, srcX, srcY, w, h, 0, 0, w, h);
5169
+ };
5170
+ const paintStatic = () => {
5171
+ const camera = store.getCamera();
5172
+ if (!cacheStale && camera.z === cacheCamZ) {
5173
+ if (viewportFitsInCache(camera)) {
5174
+ presentStatic(camera);
5175
+ return;
5176
+ }
5177
+ if (canExtend(camera)) {
5178
+ extendCache(camera);
5179
+ presentStatic(camera);
5180
+ return;
5181
+ }
5182
+ }
5183
+ renderFullCache(camera);
5184
+ presentStatic(camera);
5185
+ };
5060
5186
  const paintCustomCanvasFallback = (ctx, node, def, drawScale, env) => {
5061
5187
  if (def.getSnapshot) {
5062
5188
  const snap = def.getSnapshot(node, {
@@ -5341,6 +5467,7 @@ var createRenderer = (opts) => {
5341
5467
  const onStoreChange = () => {
5342
5468
  invalidateSortedCaches();
5343
5469
  staticDirty = true;
5470
+ cacheStale = true;
5344
5471
  interactiveDirty = true;
5345
5472
  loop.requestFrame();
5346
5473
  };
@@ -5357,6 +5484,7 @@ var createRenderer = (opts) => {
5357
5484
  interactiveDirty = true;
5358
5485
  if (state.mode === "dragging" || state.mode === "resizing" || state.mode === "rotating" || state.mode === "panning" || state.mode === "zooming" || state.mode === "idle") {
5359
5486
  staticDirty = true;
5487
+ cacheStale = true;
5360
5488
  }
5361
5489
  loop.requestFrame();
5362
5490
  };
@@ -5366,16 +5494,19 @@ var createRenderer = (opts) => {
5366
5494
  const unsubInteraction = store.subscribe("interaction", onInteractionChange);
5367
5495
  const unsubFontEpoch = subscribeFontEpoch(() => {
5368
5496
  staticDirty = true;
5497
+ cacheStale = true;
5369
5498
  loop.requestFrame();
5370
5499
  });
5371
5500
  const unsubMathEpoch = subscribeMathEpoch(() => {
5372
5501
  staticDirty = true;
5502
+ cacheStale = true;
5373
5503
  loop.requestFrame();
5374
5504
  });
5375
5505
  return {
5376
5506
  start() {
5377
5507
  loop.start();
5378
5508
  staticDirty = true;
5509
+ cacheStale = true;
5379
5510
  interactiveDirty = isInteractive(store.getInteractionState());
5380
5511
  loop.requestFrame();
5381
5512
  },
@@ -5384,14 +5515,16 @@ var createRenderer = (opts) => {
5384
5515
  },
5385
5516
  invalidate() {
5386
5517
  staticDirty = true;
5518
+ cacheStale = true;
5387
5519
  interactiveDirty = true;
5388
5520
  loop.requestFrame();
5389
5521
  },
5390
5522
  setSize(cssW, cssH) {
5391
- const a = sizeSurface(staticSurface, cssW, cssH);
5392
- const b = sizeSurface(interactiveSurface, cssW, cssH);
5523
+ const a = sizeSurface(staticSurface, cssW, cssH, maxDpr);
5524
+ const b = sizeSurface(interactiveSurface, cssW, cssH, maxDpr);
5393
5525
  if (a || b) {
5394
5526
  staticDirty = true;
5527
+ cacheStale = true;
5395
5528
  interactiveDirty = true;
5396
5529
  loop.requestFrame();
5397
5530
  }
@@ -5399,6 +5532,7 @@ var createRenderer = (opts) => {
5399
5532
  setBackground(bg) {
5400
5533
  background = bg;
5401
5534
  staticDirty = true;
5535
+ cacheStale = true;
5402
5536
  loop.requestFrame();
5403
5537
  },
5404
5538
  setSelectionColor(color) {
@@ -5409,6 +5543,7 @@ var createRenderer = (opts) => {
5409
5543
  setHideFrames(hidden) {
5410
5544
  hideFrames = hidden;
5411
5545
  staticDirty = true;
5546
+ cacheStale = true;
5412
5547
  loop.requestFrame();
5413
5548
  },
5414
5549
  stats: () => loop.stats(),
@@ -5423,6 +5558,11 @@ var createRenderer = (opts) => {
5423
5558
  unsubFontEpoch();
5424
5559
  unsubMathEpoch();
5425
5560
  assetCache.dispose();
5561
+ if (cacheSurface) {
5562
+ cacheSurface.canvas.width = 0;
5563
+ cacheSurface.canvas.height = 0;
5564
+ cacheSurface = null;
5565
+ }
5426
5566
  }
5427
5567
  };
5428
5568
  };