@fieldnotes/core 0.22.0 → 0.23.0

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.cjs CHANGED
@@ -2780,6 +2780,7 @@ var ElementRenderer = class {
2780
2780
  canvasSize = null;
2781
2781
  hexTileCache = null;
2782
2782
  hexTileCacheKey = "";
2783
+ gridBoundsOverride = null;
2783
2784
  setStore(store) {
2784
2785
  this.store = store;
2785
2786
  }
@@ -2795,6 +2796,9 @@ var ElementRenderer = class {
2795
2796
  setCanvasSize(w, h) {
2796
2797
  this.canvasSize = { w, h };
2797
2798
  }
2799
+ setGridBoundsOverride(bounds) {
2800
+ this.gridBoundsOverride = bounds;
2801
+ }
2798
2802
  isDomElement(element) {
2799
2803
  return DOM_ELEMENT_TYPES.has(element.type);
2800
2804
  }
@@ -2965,20 +2969,20 @@ var ElementRenderer = class {
2965
2969
  }
2966
2970
  }
2967
2971
  renderGrid(ctx, grid) {
2968
- if (!this.canvasSize) return;
2972
+ const canvasSize = this.canvasSize;
2973
+ if (!canvasSize) return;
2969
2974
  const cam = this.camera;
2970
2975
  if (!cam) return;
2971
- const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2972
- const bottomRight = cam.screenToWorld({
2973
- x: this.canvasSize.w,
2974
- y: this.canvasSize.h
2975
- });
2976
- const bounds = {
2977
- minX: topLeft.x,
2978
- minY: topLeft.y,
2979
- maxX: bottomRight.x,
2980
- maxY: bottomRight.y
2981
- };
2976
+ const bounds = this.gridBoundsOverride ?? (() => {
2977
+ const topLeft = cam.screenToWorld({ x: 0, y: 0 });
2978
+ const bottomRight = cam.screenToWorld({ x: canvasSize.w, y: canvasSize.h });
2979
+ return {
2980
+ minX: topLeft.x,
2981
+ minY: topLeft.y,
2982
+ maxX: bottomRight.x,
2983
+ maxY: bottomRight.y
2984
+ };
2985
+ })();
2982
2986
  if (grid.gridType === "hex") {
2983
2987
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
2984
2988
  const scale = cam.zoom * dpr;
@@ -4837,19 +4841,14 @@ var RenderLoop = class {
4837
4841
  layerManager;
4838
4842
  domNodeManager;
4839
4843
  layerCache;
4844
+ marginViewport;
4840
4845
  activeDrawingLayerId = null;
4841
- lastZoom;
4842
- lastCamX;
4843
- lastCamY;
4846
+ gridCacheDirty = true;
4847
+ // set on recenter/viewport-change; consumed by the grid block
4844
4848
  stats = new RenderStats();
4845
4849
  layerGroups = /* @__PURE__ */ new Map();
4846
4850
  gridCacheCanvas = null;
4847
4851
  gridCacheCtx = null;
4848
- gridCacheZoom = -1;
4849
- gridCacheCamX = -Infinity;
4850
- gridCacheCamY = -Infinity;
4851
- gridCacheWidth = 0;
4852
- gridCacheHeight = 0;
4853
4852
  lastGridRef = null;
4854
4853
  constructor(deps) {
4855
4854
  this.canvasEl = deps.canvasEl;
@@ -4861,9 +4860,7 @@ var RenderLoop = class {
4861
4860
  this.layerManager = deps.layerManager;
4862
4861
  this.domNodeManager = deps.domNodeManager;
4863
4862
  this.layerCache = deps.layerCache;
4864
- this.lastZoom = deps.camera.zoom;
4865
- this.lastCamX = deps.camera.position.x;
4866
- this.lastCamY = deps.camera.position.y;
4863
+ this.marginViewport = deps.marginViewport;
4867
4864
  }
4868
4865
  requestRender() {
4869
4866
  this.needsRender = true;
@@ -4890,7 +4887,9 @@ var RenderLoop = class {
4890
4887
  setCanvasSize(width, height) {
4891
4888
  this.canvasEl.width = width;
4892
4889
  this.canvasEl.height = height;
4893
- this.layerCache.resize(this.canvasEl.clientWidth, this.canvasEl.clientHeight);
4890
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4891
+ this.marginViewport.setViewport(width / dpr, height / dpr, dpr);
4892
+ this.layerCache.resize();
4894
4893
  }
4895
4894
  setActiveDrawingLayer(layerId) {
4896
4895
  this.activeDrawingLayerId = layerId;
@@ -4904,30 +4903,29 @@ var RenderLoop = class {
4904
4903
  getStats() {
4905
4904
  return this.stats.getSnapshot();
4906
4905
  }
4907
- compositeLayerCache(ctx, layerId, dpr) {
4906
+ compositeLayerCache(ctx, layerId) {
4908
4907
  const cached = this.layerCache.getCanvas(layerId);
4908
+ const offset = this.marginViewport.compositeOffset(
4909
+ this.camera.position.x,
4910
+ this.camera.position.y
4911
+ );
4909
4912
  ctx.save();
4910
- ctx.scale(1 / this.camera.zoom, 1 / this.camera.zoom);
4911
- ctx.translate(-this.camera.position.x, -this.camera.position.y);
4912
- ctx.scale(1 / dpr, 1 / dpr);
4913
- ctx.drawImage(cached, 0, 0);
4913
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
4914
+ ctx.drawImage(cached, offset.x, offset.y);
4914
4915
  ctx.restore();
4915
4916
  }
4916
- ensureGridCache(cssWidth, cssHeight, dpr) {
4917
- if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
4917
+ ensureGridCache() {
4918
+ const w = this.marginViewport.physicalWidth();
4919
+ const h = this.marginViewport.physicalHeight();
4920
+ if (this.gridCacheCanvas !== null && this.gridCacheCanvas.width === w && this.gridCacheCanvas.height === h) {
4918
4921
  return;
4919
4922
  }
4920
- const physWidth = Math.round(cssWidth * dpr);
4921
- const physHeight = Math.round(cssHeight * dpr);
4922
4923
  if (typeof OffscreenCanvas !== "undefined") {
4923
- this.gridCacheCanvas = new OffscreenCanvas(
4924
- physWidth,
4925
- physHeight
4926
- );
4924
+ this.gridCacheCanvas = new OffscreenCanvas(w, h);
4927
4925
  } else if (typeof document !== "undefined") {
4928
4926
  const el = document.createElement("canvas");
4929
- el.width = physWidth;
4930
- el.height = physHeight;
4927
+ el.width = w;
4928
+ el.height = h;
4931
4929
  this.gridCacheCanvas = el;
4932
4930
  } else {
4933
4931
  this.gridCacheCanvas = null;
@@ -4946,14 +4944,14 @@ var RenderLoop = class {
4946
4944
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4947
4945
  const cssWidth = this.canvasEl.clientWidth;
4948
4946
  const cssHeight = this.canvasEl.clientHeight;
4947
+ this.marginViewport.setViewport(cssWidth, cssHeight, dpr);
4949
4948
  const currentZoom = this.camera.zoom;
4950
4949
  const currentCamX = this.camera.position.x;
4951
4950
  const currentCamY = this.camera.position.y;
4952
- if (currentZoom !== this.lastZoom || currentCamX !== this.lastCamX || currentCamY !== this.lastCamY) {
4951
+ if (this.marginViewport.needsRecenter(currentCamX, currentCamY, currentZoom)) {
4952
+ this.marginViewport.recenter(currentCamX, currentCamY, currentZoom);
4953
4953
  this.layerCache.markAllDirty();
4954
- this.lastZoom = currentZoom;
4955
- this.lastCamX = currentCamX;
4956
- this.lastCamY = currentCamY;
4954
+ this.gridCacheDirty = true;
4957
4955
  }
4958
4956
  ctx.save();
4959
4957
  ctx.scale(dpr, dpr);
@@ -4972,13 +4970,13 @@ var RenderLoop = class {
4972
4970
  ctx.save();
4973
4971
  ctx.translate(this.camera.position.x, this.camera.position.y);
4974
4972
  ctx.scale(this.camera.zoom, this.camera.zoom);
4975
- const visibleRect = this.camera.getVisibleRect(cssWidth, cssHeight);
4976
- const margin = Math.max(visibleRect.w, visibleRect.h) * 0.1;
4973
+ const cullBounds = this.marginViewport.cachedWorldBounds();
4974
+ const cullPad = Math.max(cullBounds.w, cullBounds.h) * 0.05;
4977
4975
  const cullingRect = {
4978
- x: visibleRect.x - margin,
4979
- y: visibleRect.y - margin,
4980
- w: visibleRect.w + margin * 2,
4981
- h: visibleRect.h + margin * 2
4976
+ x: cullBounds.x - cullPad,
4977
+ y: cullBounds.y - cullPad,
4978
+ w: cullBounds.w + cullPad * 2,
4979
+ h: cullBounds.h + cullPad * 2
4982
4980
  };
4983
4981
  const allElements = this.store.getAll();
4984
4982
  this.layerGroups.clear();
@@ -5015,13 +5013,13 @@ var RenderLoop = class {
5015
5013
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
5016
5014
  if (!this.layerCache.isDirty(layerId)) {
5017
5015
  const compT0 = performance.now();
5018
- this.compositeLayerCache(ctx, layerId, dpr);
5016
+ this.compositeLayerCache(ctx, layerId);
5019
5017
  compositeMs += performance.now() - compT0;
5020
5018
  continue;
5021
5019
  }
5022
5020
  if (isActiveDrawingLayer) {
5023
5021
  const compT0 = performance.now();
5024
- this.compositeLayerCache(ctx, layerId, dpr);
5022
+ this.compositeLayerCache(ctx, layerId);
5025
5023
  compositeMs += performance.now() - compT0;
5026
5024
  continue;
5027
5025
  }
@@ -5031,9 +5029,7 @@ var RenderLoop = class {
5031
5029
  const offCanvas = this.layerCache.getCanvas(layerId);
5032
5030
  offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
5033
5031
  offCtx.save();
5034
- offCtx.scale(dpr, dpr);
5035
- offCtx.translate(this.camera.position.x, this.camera.position.y);
5036
- offCtx.scale(this.camera.zoom, this.camera.zoom);
5032
+ this.marginViewport.applyRenderTransform(offCtx);
5037
5033
  for (const element of elements) {
5038
5034
  const elBounds = getElementBounds(element);
5039
5035
  if (elBounds && !boundsIntersect(elBounds, cullingRect)) continue;
@@ -5043,48 +5039,54 @@ var RenderLoop = class {
5043
5039
  this.layerCache.markClean(layerId);
5044
5040
  layersMs += performance.now() - layerT0;
5045
5041
  const compT0 = performance.now();
5046
- this.compositeLayerCache(ctx, layerId, dpr);
5042
+ this.compositeLayerCache(ctx, layerId);
5047
5043
  compositeMs += performance.now() - compT0;
5048
5044
  }
5049
5045
  }
5050
5046
  if (gridElements.length > 0) {
5051
5047
  const gridT0 = performance.now();
5052
5048
  const gridRef = gridElements[0];
5053
- const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
5054
- if (gridCacheHit) {
5055
- ctx.save();
5056
- ctx.setTransform(1, 0, 0, 1, 0, 0);
5057
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
5058
- ctx.restore();
5059
- } else {
5060
- this.ensureGridCache(cssWidth, cssHeight, dpr);
5049
+ const gridDirty = this.gridCacheDirty || gridRef !== this.lastGridRef;
5050
+ if (gridDirty) {
5051
+ this.ensureGridCache();
5061
5052
  if (this.gridCacheCtx && this.gridCacheCanvas) {
5053
+ const cb = this.marginViewport.cachedWorldBounds();
5054
+ this.renderer.setGridBoundsOverride({
5055
+ minX: cb.x,
5056
+ minY: cb.y,
5057
+ maxX: cb.x + cb.w,
5058
+ maxY: cb.y + cb.h
5059
+ });
5062
5060
  const gc = this.gridCacheCtx;
5063
5061
  gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
5064
5062
  gc.save();
5065
- gc.scale(dpr, dpr);
5066
- gc.translate(currentCamX, currentCamY);
5067
- gc.scale(currentZoom, currentZoom);
5068
- for (const grid of gridElements) {
5069
- this.renderer.renderCanvasElement(gc, grid);
5070
- }
5071
- gc.restore();
5072
- ctx.save();
5073
- ctx.setTransform(1, 0, 0, 1, 0, 0);
5074
- ctx.drawImage(this.gridCacheCanvas, 0, 0);
5075
- ctx.restore();
5076
- } else {
5077
- for (const grid of gridElements) {
5078
- this.renderer.renderCanvasElement(ctx, grid);
5063
+ this.marginViewport.applyRenderTransform(gc);
5064
+ try {
5065
+ for (const grid of gridElements) {
5066
+ this.renderer.renderCanvasElement(gc, grid);
5067
+ }
5068
+ } finally {
5069
+ gc.restore();
5070
+ this.renderer.setGridBoundsOverride(null);
5079
5071
  }
5080
5072
  }
5081
- this.gridCacheZoom = currentZoom;
5082
- this.gridCacheCamX = currentCamX;
5083
- this.gridCacheCamY = currentCamY;
5084
- this.gridCacheWidth = cssWidth;
5085
- this.gridCacheHeight = cssHeight;
5073
+ this.gridCacheDirty = false;
5086
5074
  this.lastGridRef = gridRef;
5087
5075
  }
5076
+ if (this.gridCacheCanvas) {
5077
+ const offset = this.marginViewport.compositeOffset(
5078
+ this.camera.position.x,
5079
+ this.camera.position.y
5080
+ );
5081
+ ctx.save();
5082
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
5083
+ ctx.drawImage(this.gridCacheCanvas, offset.x, offset.y);
5084
+ ctx.restore();
5085
+ } else {
5086
+ for (const grid of gridElements) {
5087
+ this.renderer.renderCanvasElement(ctx, grid);
5088
+ }
5089
+ }
5088
5090
  gridMs = performance.now() - gridT0;
5089
5091
  }
5090
5092
  const overlayT0 = performance.now();
@@ -5116,15 +5118,11 @@ function createOffscreenCanvas(width, height) {
5116
5118
  return canvas;
5117
5119
  }
5118
5120
  var LayerCache = class {
5121
+ constructor(viewport) {
5122
+ this.viewport = viewport;
5123
+ }
5119
5124
  canvases = /* @__PURE__ */ new Map();
5120
5125
  dirtyFlags = /* @__PURE__ */ new Map();
5121
- width;
5122
- height;
5123
- constructor(width, height) {
5124
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5125
- this.width = Math.round(width * dpr);
5126
- this.height = Math.round(height * dpr);
5127
- }
5128
5126
  isDirty(layerId) {
5129
5127
  return this.dirtyFlags.get(layerId) !== false;
5130
5128
  }
@@ -5142,7 +5140,7 @@ var LayerCache = class {
5142
5140
  getCanvas(layerId) {
5143
5141
  let canvas = this.canvases.get(layerId);
5144
5142
  if (!canvas) {
5145
- canvas = createOffscreenCanvas(this.width, this.height);
5143
+ canvas = createOffscreenCanvas(this.viewport.physicalWidth(), this.viewport.physicalHeight());
5146
5144
  this.canvases.set(layerId, canvas);
5147
5145
  this.dirtyFlags.set(layerId, true);
5148
5146
  }
@@ -5152,13 +5150,12 @@ var LayerCache = class {
5152
5150
  const canvas = this.getCanvas(layerId);
5153
5151
  return canvas.getContext("2d");
5154
5152
  }
5155
- resize(width, height) {
5156
- const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
5157
- this.width = Math.round(width * dpr);
5158
- this.height = Math.round(height * dpr);
5153
+ resize() {
5154
+ const w = this.viewport.physicalWidth();
5155
+ const h = this.viewport.physicalHeight();
5159
5156
  for (const [id, canvas] of this.canvases) {
5160
- canvas.width = this.width;
5161
- canvas.height = this.height;
5157
+ canvas.width = w;
5158
+ canvas.height = h;
5162
5159
  this.dirtyFlags.set(id, true);
5163
5160
  }
5164
5161
  }
@@ -5168,6 +5165,75 @@ var LayerCache = class {
5168
5165
  }
5169
5166
  };
5170
5167
 
5168
+ // src/canvas/margin-viewport.ts
5169
+ var MarginViewport = class {
5170
+ constructor(marginPx) {
5171
+ this.marginPx = marginPx;
5172
+ }
5173
+ cssW = 0;
5174
+ cssH = 0;
5175
+ dpr = 1;
5176
+ anchorCamX = 0;
5177
+ anchorCamY = 0;
5178
+ anchorZoom = Number.NaN;
5179
+ // sentinel → first needsRecenter is true
5180
+ viewportDirty = true;
5181
+ setMargin(marginPx) {
5182
+ if (marginPx !== this.marginPx) {
5183
+ this.marginPx = marginPx;
5184
+ this.viewportDirty = true;
5185
+ }
5186
+ }
5187
+ setViewport(cssW, cssH, dpr) {
5188
+ if (cssW !== this.cssW || cssH !== this.cssH || dpr !== this.dpr) {
5189
+ this.cssW = cssW;
5190
+ this.cssH = cssH;
5191
+ this.dpr = dpr;
5192
+ this.viewportDirty = true;
5193
+ }
5194
+ }
5195
+ physicalWidth() {
5196
+ return Math.round((this.cssW + 2 * this.marginPx) * this.dpr);
5197
+ }
5198
+ physicalHeight() {
5199
+ return Math.round((this.cssH + 2 * this.marginPx) * this.dpr);
5200
+ }
5201
+ needsRecenter(camX, camY, zoom) {
5202
+ return this.viewportDirty || zoom !== this.anchorZoom || Math.abs(camX - this.anchorCamX) > this.marginPx || Math.abs(camY - this.anchorCamY) > this.marginPx;
5203
+ }
5204
+ recenter(camX, camY, zoom) {
5205
+ this.anchorCamX = camX;
5206
+ this.anchorCamY = camY;
5207
+ this.anchorZoom = zoom;
5208
+ this.viewportDirty = false;
5209
+ }
5210
+ /** Applies dpr scale + anchor-relative world transform. setViewport must have been called first. */
5211
+ applyRenderTransform(ctx) {
5212
+ ctx.scale(this.dpr, this.dpr);
5213
+ ctx.translate(this.marginPx + this.anchorCamX, this.marginPx + this.anchorCamY);
5214
+ ctx.scale(this.anchorZoom, this.anchorZoom);
5215
+ }
5216
+ // Device-px destination for drawImage(cache, x, y).
5217
+ // A world point P sits in the cache at CSS x `margin + anchorCamX + P*zoom`; it must land on
5218
+ // screen at `camX + P*zoom`; so the blit offset is `camX - anchorCamX - margin` (CSS) * dpr.
5219
+ compositeOffset(camX, camY) {
5220
+ return {
5221
+ x: (camX - this.anchorCamX - this.marginPx) * this.dpr,
5222
+ y: (camY - this.anchorCamY - this.marginPx) * this.dpr
5223
+ };
5224
+ }
5225
+ // World-space bounds of the whole cached region at the anchor (cull rect for re-renders).
5226
+ cachedWorldBounds() {
5227
+ const z = this.anchorZoom;
5228
+ return {
5229
+ x: (-this.marginPx - this.anchorCamX) / z,
5230
+ y: (-this.marginPx - this.anchorCamY) / z,
5231
+ w: (this.cssW + 2 * this.marginPx) / z,
5232
+ h: (this.cssH + 2 * this.marginPx) / z
5233
+ };
5234
+ }
5235
+ };
5236
+
5171
5237
  // src/canvas/viewport.ts
5172
5238
  var Viewport = class {
5173
5239
  constructor(container, options = {}) {
@@ -5244,10 +5310,13 @@ var Viewport = class {
5244
5310
  this.interactMode = new InteractMode({
5245
5311
  getNode: (id) => this.domNodeManager.getNode(id)
5246
5312
  });
5247
- const layerCache = new LayerCache(
5313
+ this.marginViewport = new MarginViewport(options.panBufferMargin ?? 256);
5314
+ this.marginViewport.setViewport(
5248
5315
  this.canvasEl.clientWidth || 800,
5249
- this.canvasEl.clientHeight || 600
5316
+ this.canvasEl.clientHeight || 600,
5317
+ typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1
5250
5318
  );
5319
+ const layerCache = new LayerCache(this.marginViewport);
5251
5320
  this.renderLoop = new RenderLoop({
5252
5321
  canvasEl: this.canvasEl,
5253
5322
  camera: this.camera,
@@ -5257,7 +5326,8 @@ var Viewport = class {
5257
5326
  toolManager: this.toolManager,
5258
5327
  layerManager: this.layerManager,
5259
5328
  domNodeManager: this.domNodeManager,
5260
- layerCache
5329
+ layerCache,
5330
+ marginViewport: this.marginViewport
5261
5331
  });
5262
5332
  this.unsubCamera = this.camera.onChange(() => {
5263
5333
  this.applyCameraTransform();
@@ -5321,6 +5391,7 @@ var Viewport = class {
5321
5391
  noteEditor;
5322
5392
  historyRecorder;
5323
5393
  toolContext;
5394
+ marginViewport;
5324
5395
  resizeObserver = null;
5325
5396
  _snapToGrid = false;
5326
5397
  _gridSize;
@@ -7511,7 +7582,7 @@ var TemplateTool = class {
7511
7582
  };
7512
7583
 
7513
7584
  // src/index.ts
7514
- var VERSION = "0.22.0";
7585
+ var VERSION = "0.23.0";
7515
7586
  // Annotate the CommonJS export names for ESM import in node:
7516
7587
  0 && (module.exports = {
7517
7588
  AddElementCommand,