@fieldnotes/core 0.8.8 → 0.8.10

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
@@ -1404,58 +1404,6 @@ function getSquareGridLines(bounds, cellSize) {
1404
1404
  }
1405
1405
  return { verticals, horizontals };
1406
1406
  }
1407
- function getHexVertices(cx, cy, circumradius, orientation) {
1408
- const vertices = [];
1409
- const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1410
- for (let i = 0; i < 6; i++) {
1411
- const angle = Math.PI / 3 * i + angleOffset;
1412
- vertices.push({
1413
- x: cx + circumradius * Math.cos(angle),
1414
- y: cy + circumradius * Math.sin(angle)
1415
- });
1416
- }
1417
- return vertices;
1418
- }
1419
- function getHexCenters(bounds, circumradius, orientation) {
1420
- if (circumradius <= 0) return [];
1421
- const centers = [];
1422
- if (orientation === "pointy") {
1423
- const hexW = Math.sqrt(3) * circumradius;
1424
- const hexH = 2 * circumradius;
1425
- const rowH = hexH * 0.75;
1426
- const startRow = Math.floor((bounds.minY - circumradius) / rowH);
1427
- const endRow = Math.ceil((bounds.maxY + circumradius) / rowH);
1428
- const startCol = Math.floor((bounds.minX - hexW) / hexW);
1429
- const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
1430
- for (let row = startRow; row <= endRow; row++) {
1431
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1432
- for (let col = startCol; col <= endCol; col++) {
1433
- centers.push({
1434
- x: col * hexW + offsetX,
1435
- y: row * rowH
1436
- });
1437
- }
1438
- }
1439
- } else {
1440
- const hexW = 2 * circumradius;
1441
- const hexH = Math.sqrt(3) * circumradius;
1442
- const colW = hexW * 0.75;
1443
- const startCol = Math.floor((bounds.minX - circumradius) / colW);
1444
- const endCol = Math.ceil((bounds.maxX + circumradius) / colW);
1445
- const startRow = Math.floor((bounds.minY - hexH) / hexH);
1446
- const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
1447
- for (let col = startCol; col <= endCol; col++) {
1448
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1449
- for (let row = startRow; row <= endRow; row++) {
1450
- centers.push({
1451
- x: col * colW,
1452
- y: row * hexH + offsetY
1453
- });
1454
- }
1455
- }
1456
- }
1457
- return centers;
1458
- }
1459
1407
  function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opacity) {
1460
1408
  if (cellSize <= 0) return;
1461
1409
  const { verticals, horizontals } = getSquareGridLines(bounds, cellSize);
@@ -1477,27 +1425,167 @@ function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opaci
1477
1425
  }
1478
1426
  function renderHexGrid(ctx, bounds, cellSize, orientation, strokeColor, strokeWidth, opacity) {
1479
1427
  if (cellSize <= 0) return;
1480
- const centers = getHexCenters(bounds, cellSize, orientation);
1428
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1429
+ const ox0 = cellSize * Math.cos(angleOffset);
1430
+ const oy0 = cellSize * Math.sin(angleOffset);
1431
+ const ox1 = cellSize * Math.cos(Math.PI / 3 + angleOffset);
1432
+ const oy1 = cellSize * Math.sin(Math.PI / 3 + angleOffset);
1433
+ const ox2 = cellSize * Math.cos(2 * Math.PI / 3 + angleOffset);
1434
+ const oy2 = cellSize * Math.sin(2 * Math.PI / 3 + angleOffset);
1435
+ const ox3 = cellSize * Math.cos(Math.PI + angleOffset);
1436
+ const oy3 = cellSize * Math.sin(Math.PI + angleOffset);
1437
+ const ox4 = cellSize * Math.cos(4 * Math.PI / 3 + angleOffset);
1438
+ const oy4 = cellSize * Math.sin(4 * Math.PI / 3 + angleOffset);
1439
+ const ox5 = cellSize * Math.cos(5 * Math.PI / 3 + angleOffset);
1440
+ const oy5 = cellSize * Math.sin(5 * Math.PI / 3 + angleOffset);
1481
1441
  ctx.save();
1482
1442
  ctx.strokeStyle = strokeColor;
1483
1443
  ctx.lineWidth = strokeWidth;
1484
1444
  ctx.globalAlpha = opacity;
1485
1445
  ctx.beginPath();
1486
- for (const center of centers) {
1487
- const verts = getHexVertices(center.x, center.y, cellSize, orientation);
1488
- const first = verts[0];
1489
- if (!first) continue;
1490
- ctx.moveTo(first.x, first.y);
1491
- for (let i = 1; i < verts.length; i++) {
1492
- const v = verts[i];
1493
- if (!v) continue;
1494
- ctx.lineTo(v.x, v.y);
1446
+ if (orientation === "pointy") {
1447
+ const hexW = Math.sqrt(3) * cellSize;
1448
+ const rowH = 1.5 * cellSize;
1449
+ const startRow = Math.floor((bounds.minY - cellSize) / rowH);
1450
+ const endRow = Math.ceil((bounds.maxY + cellSize) / rowH);
1451
+ const startCol = Math.floor((bounds.minX - hexW) / hexW);
1452
+ const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
1453
+ for (let row = startRow; row <= endRow; row++) {
1454
+ const offX = row % 2 !== 0 ? hexW / 2 : 0;
1455
+ for (let col = startCol; col <= endCol; col++) {
1456
+ const cx = col * hexW + offX;
1457
+ const cy = row * rowH;
1458
+ ctx.moveTo(cx + ox0, cy + oy0);
1459
+ ctx.lineTo(cx + ox1, cy + oy1);
1460
+ ctx.lineTo(cx + ox2, cy + oy2);
1461
+ ctx.lineTo(cx + ox3, cy + oy3);
1462
+ ctx.lineTo(cx + ox4, cy + oy4);
1463
+ ctx.lineTo(cx + ox5, cy + oy5);
1464
+ ctx.closePath();
1465
+ }
1466
+ }
1467
+ } else {
1468
+ const hexH = Math.sqrt(3) * cellSize;
1469
+ const colW = 1.5 * cellSize;
1470
+ const startCol = Math.floor((bounds.minX - cellSize) / colW);
1471
+ const endCol = Math.ceil((bounds.maxX + cellSize) / colW);
1472
+ const startRow = Math.floor((bounds.minY - hexH) / hexH);
1473
+ const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
1474
+ for (let col = startCol; col <= endCol; col++) {
1475
+ const offY = col % 2 !== 0 ? hexH / 2 : 0;
1476
+ for (let row = startRow; row <= endRow; row++) {
1477
+ const cx = col * colW;
1478
+ const cy = row * hexH + offY;
1479
+ ctx.moveTo(cx + ox0, cy + oy0);
1480
+ ctx.lineTo(cx + ox1, cy + oy1);
1481
+ ctx.lineTo(cx + ox2, cy + oy2);
1482
+ ctx.lineTo(cx + ox3, cy + oy3);
1483
+ ctx.lineTo(cx + ox4, cy + oy4);
1484
+ ctx.lineTo(cx + ox5, cy + oy5);
1485
+ ctx.closePath();
1486
+ }
1495
1487
  }
1496
- ctx.closePath();
1497
1488
  }
1498
1489
  ctx.stroke();
1499
1490
  ctx.restore();
1500
1491
  }
1492
+ function createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1493
+ let tileW;
1494
+ let tileH;
1495
+ if (orientation === "pointy") {
1496
+ tileW = Math.sqrt(3) * cellSize;
1497
+ tileH = 3 * cellSize;
1498
+ } else {
1499
+ tileW = 3 * cellSize;
1500
+ tileH = Math.sqrt(3) * cellSize;
1501
+ }
1502
+ const pxW = Math.ceil(tileW * scale);
1503
+ const pxH = Math.ceil(tileH * scale);
1504
+ if (pxW <= 0 || pxH <= 0) return null;
1505
+ let canvas;
1506
+ if (typeof OffscreenCanvas !== "undefined") {
1507
+ canvas = new OffscreenCanvas(pxW, pxH);
1508
+ } else if (typeof document !== "undefined") {
1509
+ const el = document.createElement("canvas");
1510
+ el.width = pxW;
1511
+ el.height = pxH;
1512
+ canvas = el;
1513
+ } else {
1514
+ return null;
1515
+ }
1516
+ const tc = canvas.getContext("2d");
1517
+ if (!tc) return null;
1518
+ tc.scale(scale, scale);
1519
+ tc.beginPath();
1520
+ tc.rect(0, 0, tileW, tileH);
1521
+ tc.clip();
1522
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1523
+ const ox0 = cellSize * Math.cos(angleOffset);
1524
+ const oy0 = cellSize * Math.sin(angleOffset);
1525
+ const ox1 = cellSize * Math.cos(Math.PI / 3 + angleOffset);
1526
+ const oy1 = cellSize * Math.sin(Math.PI / 3 + angleOffset);
1527
+ const ox2 = cellSize * Math.cos(2 * Math.PI / 3 + angleOffset);
1528
+ const oy2 = cellSize * Math.sin(2 * Math.PI / 3 + angleOffset);
1529
+ const ox3 = cellSize * Math.cos(Math.PI + angleOffset);
1530
+ const oy3 = cellSize * Math.sin(Math.PI + angleOffset);
1531
+ const ox4 = cellSize * Math.cos(4 * Math.PI / 3 + angleOffset);
1532
+ const oy4 = cellSize * Math.sin(4 * Math.PI / 3 + angleOffset);
1533
+ const ox5 = cellSize * Math.cos(5 * Math.PI / 3 + angleOffset);
1534
+ const oy5 = cellSize * Math.sin(5 * Math.PI / 3 + angleOffset);
1535
+ tc.strokeStyle = strokeColor;
1536
+ tc.lineWidth = strokeWidth;
1537
+ tc.globalAlpha = opacity;
1538
+ tc.beginPath();
1539
+ if (orientation === "pointy") {
1540
+ const hexW = tileW;
1541
+ const rowH = 1.5 * cellSize;
1542
+ for (let row = -1; row <= 3; row++) {
1543
+ const offX = row % 2 !== 0 ? hexW / 2 : 0;
1544
+ for (let col = -1; col <= 1; col++) {
1545
+ const cx = col * hexW + offX;
1546
+ const cy = row * rowH;
1547
+ tc.moveTo(cx + ox0, cy + oy0);
1548
+ tc.lineTo(cx + ox1, cy + oy1);
1549
+ tc.lineTo(cx + ox2, cy + oy2);
1550
+ tc.lineTo(cx + ox3, cy + oy3);
1551
+ tc.lineTo(cx + ox4, cy + oy4);
1552
+ tc.lineTo(cx + ox5, cy + oy5);
1553
+ tc.closePath();
1554
+ }
1555
+ }
1556
+ } else {
1557
+ const hexH = tileH;
1558
+ const colW = 1.5 * cellSize;
1559
+ for (let col = -1; col <= 3; col++) {
1560
+ const offY = col % 2 !== 0 ? hexH / 2 : 0;
1561
+ for (let row = -1; row <= 1; row++) {
1562
+ const cx = col * colW;
1563
+ const cy = row * hexH + offY;
1564
+ tc.moveTo(cx + ox0, cy + oy0);
1565
+ tc.lineTo(cx + ox1, cy + oy1);
1566
+ tc.lineTo(cx + ox2, cy + oy2);
1567
+ tc.lineTo(cx + ox3, cy + oy3);
1568
+ tc.lineTo(cx + ox4, cy + oy4);
1569
+ tc.lineTo(cx + ox5, cy + oy5);
1570
+ tc.closePath();
1571
+ }
1572
+ }
1573
+ }
1574
+ tc.stroke();
1575
+ return { canvas, tileW, tileH };
1576
+ }
1577
+ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1578
+ const { tileW, tileH } = tile;
1579
+ const startCol = Math.floor(bounds.minX / tileW) - 1;
1580
+ const endCol = Math.ceil(bounds.maxX / tileW) + 1;
1581
+ const startRow = Math.floor(bounds.minY / tileH) - 1;
1582
+ const endRow = Math.ceil(bounds.maxY / tileH) + 1;
1583
+ for (let row = startRow; row <= endRow; row++) {
1584
+ for (let col = startCol; col <= endCol; col++) {
1585
+ ctx.drawImage(tile.canvas, col * tileW, row * tileH, tileW, tileH);
1586
+ }
1587
+ }
1588
+ }
1501
1589
 
1502
1590
  // src/elements/element-renderer.ts
1503
1591
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
@@ -1509,6 +1597,8 @@ var ElementRenderer = class {
1509
1597
  onImageLoad = null;
1510
1598
  camera = null;
1511
1599
  canvasSize = null;
1600
+ hexTileCache = null;
1601
+ hexTileCacheKey = "";
1512
1602
  setStore(store) {
1513
1603
  this.store = store;
1514
1604
  }
@@ -1694,15 +1784,29 @@ var ElementRenderer = class {
1694
1784
  maxY: bottomRight.y
1695
1785
  };
1696
1786
  if (grid.gridType === "hex") {
1697
- renderHexGrid(
1698
- ctx,
1699
- bounds,
1787
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1788
+ const scale = cam.zoom * dpr;
1789
+ const tile = this.getHexTile(
1700
1790
  grid.cellSize,
1701
1791
  grid.hexOrientation,
1702
1792
  grid.strokeColor,
1703
1793
  grid.strokeWidth,
1704
- grid.opacity
1794
+ grid.opacity,
1795
+ scale
1705
1796
  );
1797
+ if (tile) {
1798
+ renderHexGridTiled(ctx, bounds, grid.cellSize, tile);
1799
+ } else {
1800
+ renderHexGrid(
1801
+ ctx,
1802
+ bounds,
1803
+ grid.cellSize,
1804
+ grid.hexOrientation,
1805
+ grid.strokeColor,
1806
+ grid.strokeWidth,
1807
+ grid.opacity
1808
+ );
1809
+ }
1706
1810
  } else {
1707
1811
  renderSquareGrid(
1708
1812
  ctx,
@@ -1725,6 +1829,18 @@ var ElementRenderer = class {
1725
1829
  image.size.h
1726
1830
  );
1727
1831
  }
1832
+ getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1833
+ const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
1834
+ if (this.hexTileCacheKey === key && this.hexTileCache) {
1835
+ return this.hexTileCache;
1836
+ }
1837
+ const tile = createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale);
1838
+ if (tile) {
1839
+ this.hexTileCache = tile;
1840
+ this.hexTileCacheKey = key;
1841
+ }
1842
+ return tile;
1843
+ }
1728
1844
  getImage(src) {
1729
1845
  const cached = this.imageCache.get(src);
1730
1846
  if (cached) {
@@ -2825,17 +2941,19 @@ var SAMPLE_SIZE = 60;
2825
2941
  var RenderStats = class {
2826
2942
  frameTimes = [];
2827
2943
  frameCount = 0;
2828
- recordFrame(durationMs) {
2944
+ _lastGridMs = 0;
2945
+ recordFrame(durationMs, gridMs) {
2829
2946
  this.frameCount++;
2830
2947
  this.frameTimes.push(durationMs);
2831
2948
  if (this.frameTimes.length > SAMPLE_SIZE) {
2832
2949
  this.frameTimes.shift();
2833
2950
  }
2951
+ if (gridMs !== void 0) this._lastGridMs = gridMs;
2834
2952
  }
2835
2953
  getSnapshot() {
2836
2954
  const times = this.frameTimes;
2837
2955
  if (times.length === 0) {
2838
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2956
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
2839
2957
  }
2840
2958
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
2841
2959
  const sorted = [...times].sort((a, b) => a - b);
@@ -2846,6 +2964,7 @@ var RenderStats = class {
2846
2964
  avgFrameMs: Math.round(avg * 100) / 100,
2847
2965
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2848
2966
  lastFrameMs: Math.round(lastFrame * 100) / 100,
2967
+ lastGridMs: Math.round(this._lastGridMs * 100) / 100,
2849
2968
  frameCount: this.frameCount
2850
2969
  };
2851
2970
  }
@@ -2873,6 +2992,15 @@ var RenderLoop = class {
2873
2992
  lastCamX;
2874
2993
  lastCamY;
2875
2994
  stats = new RenderStats();
2995
+ lastGridMs = 0;
2996
+ gridCacheCanvas = null;
2997
+ gridCacheCtx = null;
2998
+ gridCacheZoom = -1;
2999
+ gridCacheCamX = -Infinity;
3000
+ gridCacheCamY = -Infinity;
3001
+ gridCacheWidth = 0;
3002
+ gridCacheHeight = 0;
3003
+ lastGridRef = null;
2876
3004
  constructor(deps) {
2877
3005
  this.canvasEl = deps.canvasEl;
2878
3006
  this.camera = deps.camera;
@@ -2935,6 +3063,29 @@ var RenderLoop = class {
2935
3063
  ctx.drawImage(cached, 0, 0);
2936
3064
  ctx.restore();
2937
3065
  }
3066
+ ensureGridCache(cssWidth, cssHeight, dpr) {
3067
+ if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
3068
+ return;
3069
+ }
3070
+ const physWidth = Math.round(cssWidth * dpr);
3071
+ const physHeight = Math.round(cssHeight * dpr);
3072
+ if (typeof OffscreenCanvas !== "undefined") {
3073
+ this.gridCacheCanvas = new OffscreenCanvas(
3074
+ physWidth,
3075
+ physHeight
3076
+ );
3077
+ } else if (typeof document !== "undefined") {
3078
+ const el = document.createElement("canvas");
3079
+ el.width = physWidth;
3080
+ el.height = physHeight;
3081
+ this.gridCacheCanvas = el;
3082
+ } else {
3083
+ this.gridCacheCanvas = null;
3084
+ this.gridCacheCtx = null;
3085
+ return;
3086
+ }
3087
+ this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
3088
+ }
2938
3089
  render() {
2939
3090
  const t0 = performance.now();
2940
3091
  const ctx = this.canvasEl.getContext("2d");
@@ -2997,9 +3148,6 @@ var RenderLoop = class {
2997
3148
  }
2998
3149
  group.push(element);
2999
3150
  }
3000
- for (const grid of gridElements) {
3001
- this.renderer.renderCanvasElement(ctx, grid);
3002
- }
3003
3151
  for (const [layerId, elements] of layerElements) {
3004
3152
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
3005
3153
  if (!this.layerCache.isDirty(layerId)) {
@@ -3028,13 +3176,53 @@ var RenderLoop = class {
3028
3176
  this.compositeLayerCache(ctx, layerId, dpr);
3029
3177
  }
3030
3178
  }
3179
+ if (gridElements.length > 0) {
3180
+ const gridT0 = performance.now();
3181
+ const gridRef = gridElements[0];
3182
+ const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
3183
+ if (gridCacheHit) {
3184
+ ctx.save();
3185
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3186
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3187
+ ctx.restore();
3188
+ } else {
3189
+ this.ensureGridCache(cssWidth, cssHeight, dpr);
3190
+ if (this.gridCacheCtx && this.gridCacheCanvas) {
3191
+ const gc = this.gridCacheCtx;
3192
+ gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
3193
+ gc.save();
3194
+ gc.scale(dpr, dpr);
3195
+ gc.translate(currentCamX, currentCamY);
3196
+ gc.scale(currentZoom, currentZoom);
3197
+ for (const grid of gridElements) {
3198
+ this.renderer.renderCanvasElement(gc, grid);
3199
+ }
3200
+ gc.restore();
3201
+ ctx.save();
3202
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3203
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3204
+ ctx.restore();
3205
+ } else {
3206
+ for (const grid of gridElements) {
3207
+ this.renderer.renderCanvasElement(ctx, grid);
3208
+ }
3209
+ }
3210
+ this.gridCacheZoom = currentZoom;
3211
+ this.gridCacheCamX = currentCamX;
3212
+ this.gridCacheCamY = currentCamY;
3213
+ this.gridCacheWidth = cssWidth;
3214
+ this.gridCacheHeight = cssHeight;
3215
+ this.lastGridRef = gridRef;
3216
+ }
3217
+ this.lastGridMs = performance.now() - gridT0;
3218
+ }
3031
3219
  const activeTool = this.toolManager.activeTool;
3032
3220
  if (activeTool?.renderOverlay) {
3033
3221
  activeTool.renderOverlay(ctx);
3034
3222
  }
3035
3223
  ctx.restore();
3036
3224
  ctx.restore();
3037
- this.stats.recordFrame(performance.now() - t0);
3225
+ this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
3038
3226
  }
3039
3227
  };
3040
3228
 
@@ -3334,6 +3522,18 @@ var Viewport = class {
3334
3522
  this.historyRecorder.commit();
3335
3523
  this.requestRender();
3336
3524
  }
3525
+ getRenderStats() {
3526
+ return this.renderLoop.getStats();
3527
+ }
3528
+ logPerformance(intervalMs = 2e3) {
3529
+ const id = setInterval(() => {
3530
+ const s = this.getRenderStats();
3531
+ console.log(
3532
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
3533
+ );
3534
+ }, intervalMs);
3535
+ return () => clearInterval(id);
3536
+ }
3337
3537
  destroy() {
3338
3538
  this.renderLoop.stop();
3339
3539
  this.interactMode.destroy();
@@ -4718,7 +4918,7 @@ var UpdateLayerCommand = class {
4718
4918
  };
4719
4919
 
4720
4920
  // src/index.ts
4721
- var VERSION = "0.8.8";
4921
+ var VERSION = "0.8.10";
4722
4922
  // Annotate the CommonJS export names for ESM import in node:
4723
4923
  0 && (module.exports = {
4724
4924
  AddElementCommand,
package/dist/index.d.cts CHANGED
@@ -440,6 +440,15 @@ interface ExportImageOptions {
440
440
  }
441
441
  declare function exportImage(store: ElementStore, options?: ExportImageOptions, layerManager?: LayerManager): Promise<Blob | null>;
442
442
 
443
+ interface RenderStatsSnapshot {
444
+ fps: number;
445
+ avgFrameMs: number;
446
+ p95FrameMs: number;
447
+ lastFrameMs: number;
448
+ lastGridMs: number;
449
+ frameCount: number;
450
+ }
451
+
443
452
  interface ViewportOptions {
444
453
  camera?: CameraOptions;
445
454
  background?: BackgroundOptions;
@@ -504,6 +513,8 @@ declare class Viewport {
504
513
  }): string;
505
514
  updateGrid(updates: Partial<Pick<GridElement, 'gridType' | 'hexOrientation' | 'cellSize' | 'strokeColor' | 'strokeWidth' | 'opacity'>>): void;
506
515
  removeGrid(): void;
516
+ getRenderStats(): RenderStatsSnapshot;
517
+ logPerformance(intervalMs?: number): () => void;
507
518
  destroy(): void;
508
519
  private startEditingElement;
509
520
  private onTextEditStop;
@@ -521,20 +532,14 @@ declare class Viewport {
521
532
  private observeResize;
522
533
  }
523
534
 
524
- interface RenderStatsSnapshot {
525
- fps: number;
526
- avgFrameMs: number;
527
- p95FrameMs: number;
528
- lastFrameMs: number;
529
- frameCount: number;
530
- }
531
-
532
535
  declare class ElementRenderer {
533
536
  private store;
534
537
  private imageCache;
535
538
  private onImageLoad;
536
539
  private camera;
537
540
  private canvasSize;
541
+ private hexTileCache;
542
+ private hexTileCacheKey;
538
543
  setStore(store: ElementStore): void;
539
544
  setOnImageLoad(callback: () => void): void;
540
545
  setCamera(camera: Camera): void;
@@ -550,6 +555,7 @@ declare class ElementRenderer {
550
555
  private strokeShapePath;
551
556
  private renderGrid;
552
557
  private renderImage;
558
+ private getHexTile;
553
559
  private getImage;
554
560
  }
555
561
 
@@ -924,6 +930,6 @@ declare class UpdateLayerCommand implements Command {
924
930
  undo(_store: ElementStore): void;
925
931
  }
926
932
 
927
- declare const VERSION = "0.8.8";
933
+ declare const VERSION = "0.8.10";
928
934
 
929
935
  export { AddElementCommand, type ArrowElement, ArrowTool, type ArrowToolOptions, AutoSave, type AutoSaveOptions, Background, type BackgroundOptions, type BackgroundPattern, BatchCommand, type Binding, type Bounds, Camera, type CameraChangeInfo, type CameraOptions, type CanvasElement, type CanvasState, type Command, CreateLayerCommand, ElementRenderer, ElementStore, type ElementType, type ElementUpdateEvent, EraserTool, type EraserToolOptions, EventBus, type ExportImageOptions, type GridElement, HandTool, type HexOrientation, HistoryRecorder, HistoryStack, type HistoryStackOptions, type HtmlElement, type ImageElement, ImageTool, type ImageToolOptions, InputHandler, type Layer, LayerManager, NoteEditor, type NoteElement, NoteTool, type NoteToolOptions, PencilTool, type PencilToolOptions, type Point, type PointerState, Quadtree, RemoveElementCommand, RemoveLayerCommand, type RenderStatsSnapshot, SelectTool, type ShapeElement, type ShapeKind, ShapeTool, type ShapeToolOptions, type Size, type StrokeElement, type StrokePoint, type TextElement, TextTool, type TextToolOptions, type Tool, type ToolContext, ToolManager, type ToolName, UpdateElementCommand, UpdateLayerCommand, VERSION, Viewport, type ViewportOptions, boundsIntersect, clearStaleBindings, createArrow, createGrid, createHtmlElement, createId, createImage, createNote, createShape, createStroke, createText, exportImage, exportState, findBindTarget, findBoundArrows, getArrowBounds, getArrowControlPoint, getArrowMidpoint, getArrowTangentAngle, getBendFromPoint, getEdgeIntersection, getElementBounds, getElementCenter, isBindable, isNearBezier, parseState, snapPoint, unbindArrow, updateBoundArrow };
package/dist/index.d.ts CHANGED
@@ -440,6 +440,15 @@ interface ExportImageOptions {
440
440
  }
441
441
  declare function exportImage(store: ElementStore, options?: ExportImageOptions, layerManager?: LayerManager): Promise<Blob | null>;
442
442
 
443
+ interface RenderStatsSnapshot {
444
+ fps: number;
445
+ avgFrameMs: number;
446
+ p95FrameMs: number;
447
+ lastFrameMs: number;
448
+ lastGridMs: number;
449
+ frameCount: number;
450
+ }
451
+
443
452
  interface ViewportOptions {
444
453
  camera?: CameraOptions;
445
454
  background?: BackgroundOptions;
@@ -504,6 +513,8 @@ declare class Viewport {
504
513
  }): string;
505
514
  updateGrid(updates: Partial<Pick<GridElement, 'gridType' | 'hexOrientation' | 'cellSize' | 'strokeColor' | 'strokeWidth' | 'opacity'>>): void;
506
515
  removeGrid(): void;
516
+ getRenderStats(): RenderStatsSnapshot;
517
+ logPerformance(intervalMs?: number): () => void;
507
518
  destroy(): void;
508
519
  private startEditingElement;
509
520
  private onTextEditStop;
@@ -521,20 +532,14 @@ declare class Viewport {
521
532
  private observeResize;
522
533
  }
523
534
 
524
- interface RenderStatsSnapshot {
525
- fps: number;
526
- avgFrameMs: number;
527
- p95FrameMs: number;
528
- lastFrameMs: number;
529
- frameCount: number;
530
- }
531
-
532
535
  declare class ElementRenderer {
533
536
  private store;
534
537
  private imageCache;
535
538
  private onImageLoad;
536
539
  private camera;
537
540
  private canvasSize;
541
+ private hexTileCache;
542
+ private hexTileCacheKey;
538
543
  setStore(store: ElementStore): void;
539
544
  setOnImageLoad(callback: () => void): void;
540
545
  setCamera(camera: Camera): void;
@@ -550,6 +555,7 @@ declare class ElementRenderer {
550
555
  private strokeShapePath;
551
556
  private renderGrid;
552
557
  private renderImage;
558
+ private getHexTile;
553
559
  private getImage;
554
560
  }
555
561
 
@@ -924,6 +930,6 @@ declare class UpdateLayerCommand implements Command {
924
930
  undo(_store: ElementStore): void;
925
931
  }
926
932
 
927
- declare const VERSION = "0.8.8";
933
+ declare const VERSION = "0.8.10";
928
934
 
929
935
  export { AddElementCommand, type ArrowElement, ArrowTool, type ArrowToolOptions, AutoSave, type AutoSaveOptions, Background, type BackgroundOptions, type BackgroundPattern, BatchCommand, type Binding, type Bounds, Camera, type CameraChangeInfo, type CameraOptions, type CanvasElement, type CanvasState, type Command, CreateLayerCommand, ElementRenderer, ElementStore, type ElementType, type ElementUpdateEvent, EraserTool, type EraserToolOptions, EventBus, type ExportImageOptions, type GridElement, HandTool, type HexOrientation, HistoryRecorder, HistoryStack, type HistoryStackOptions, type HtmlElement, type ImageElement, ImageTool, type ImageToolOptions, InputHandler, type Layer, LayerManager, NoteEditor, type NoteElement, NoteTool, type NoteToolOptions, PencilTool, type PencilToolOptions, type Point, type PointerState, Quadtree, RemoveElementCommand, RemoveLayerCommand, type RenderStatsSnapshot, SelectTool, type ShapeElement, type ShapeKind, ShapeTool, type ShapeToolOptions, type Size, type StrokeElement, type StrokePoint, type TextElement, TextTool, type TextToolOptions, type Tool, type ToolContext, ToolManager, type ToolName, UpdateElementCommand, UpdateLayerCommand, VERSION, Viewport, type ViewportOptions, boundsIntersect, clearStaleBindings, createArrow, createGrid, createHtmlElement, createId, createImage, createNote, createShape, createStroke, createText, exportImage, exportState, findBindTarget, findBoundArrows, getArrowBounds, getArrowControlPoint, getArrowMidpoint, getArrowTangentAngle, getBendFromPoint, getEdgeIntersection, getElementBounds, getElementCenter, isBindable, isNearBezier, parseState, snapPoint, unbindArrow, updateBoundArrow };
package/dist/index.js CHANGED
@@ -1319,58 +1319,6 @@ function getSquareGridLines(bounds, cellSize) {
1319
1319
  }
1320
1320
  return { verticals, horizontals };
1321
1321
  }
1322
- function getHexVertices(cx, cy, circumradius, orientation) {
1323
- const vertices = [];
1324
- const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1325
- for (let i = 0; i < 6; i++) {
1326
- const angle = Math.PI / 3 * i + angleOffset;
1327
- vertices.push({
1328
- x: cx + circumradius * Math.cos(angle),
1329
- y: cy + circumradius * Math.sin(angle)
1330
- });
1331
- }
1332
- return vertices;
1333
- }
1334
- function getHexCenters(bounds, circumradius, orientation) {
1335
- if (circumradius <= 0) return [];
1336
- const centers = [];
1337
- if (orientation === "pointy") {
1338
- const hexW = Math.sqrt(3) * circumradius;
1339
- const hexH = 2 * circumradius;
1340
- const rowH = hexH * 0.75;
1341
- const startRow = Math.floor((bounds.minY - circumradius) / rowH);
1342
- const endRow = Math.ceil((bounds.maxY + circumradius) / rowH);
1343
- const startCol = Math.floor((bounds.minX - hexW) / hexW);
1344
- const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
1345
- for (let row = startRow; row <= endRow; row++) {
1346
- const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1347
- for (let col = startCol; col <= endCol; col++) {
1348
- centers.push({
1349
- x: col * hexW + offsetX,
1350
- y: row * rowH
1351
- });
1352
- }
1353
- }
1354
- } else {
1355
- const hexW = 2 * circumradius;
1356
- const hexH = Math.sqrt(3) * circumradius;
1357
- const colW = hexW * 0.75;
1358
- const startCol = Math.floor((bounds.minX - circumradius) / colW);
1359
- const endCol = Math.ceil((bounds.maxX + circumradius) / colW);
1360
- const startRow = Math.floor((bounds.minY - hexH) / hexH);
1361
- const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
1362
- for (let col = startCol; col <= endCol; col++) {
1363
- const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1364
- for (let row = startRow; row <= endRow; row++) {
1365
- centers.push({
1366
- x: col * colW,
1367
- y: row * hexH + offsetY
1368
- });
1369
- }
1370
- }
1371
- }
1372
- return centers;
1373
- }
1374
1322
  function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opacity) {
1375
1323
  if (cellSize <= 0) return;
1376
1324
  const { verticals, horizontals } = getSquareGridLines(bounds, cellSize);
@@ -1392,27 +1340,167 @@ function renderSquareGrid(ctx, bounds, cellSize, strokeColor, strokeWidth, opaci
1392
1340
  }
1393
1341
  function renderHexGrid(ctx, bounds, cellSize, orientation, strokeColor, strokeWidth, opacity) {
1394
1342
  if (cellSize <= 0) return;
1395
- const centers = getHexCenters(bounds, cellSize, orientation);
1343
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1344
+ const ox0 = cellSize * Math.cos(angleOffset);
1345
+ const oy0 = cellSize * Math.sin(angleOffset);
1346
+ const ox1 = cellSize * Math.cos(Math.PI / 3 + angleOffset);
1347
+ const oy1 = cellSize * Math.sin(Math.PI / 3 + angleOffset);
1348
+ const ox2 = cellSize * Math.cos(2 * Math.PI / 3 + angleOffset);
1349
+ const oy2 = cellSize * Math.sin(2 * Math.PI / 3 + angleOffset);
1350
+ const ox3 = cellSize * Math.cos(Math.PI + angleOffset);
1351
+ const oy3 = cellSize * Math.sin(Math.PI + angleOffset);
1352
+ const ox4 = cellSize * Math.cos(4 * Math.PI / 3 + angleOffset);
1353
+ const oy4 = cellSize * Math.sin(4 * Math.PI / 3 + angleOffset);
1354
+ const ox5 = cellSize * Math.cos(5 * Math.PI / 3 + angleOffset);
1355
+ const oy5 = cellSize * Math.sin(5 * Math.PI / 3 + angleOffset);
1396
1356
  ctx.save();
1397
1357
  ctx.strokeStyle = strokeColor;
1398
1358
  ctx.lineWidth = strokeWidth;
1399
1359
  ctx.globalAlpha = opacity;
1400
1360
  ctx.beginPath();
1401
- for (const center of centers) {
1402
- const verts = getHexVertices(center.x, center.y, cellSize, orientation);
1403
- const first = verts[0];
1404
- if (!first) continue;
1405
- ctx.moveTo(first.x, first.y);
1406
- for (let i = 1; i < verts.length; i++) {
1407
- const v = verts[i];
1408
- if (!v) continue;
1409
- ctx.lineTo(v.x, v.y);
1361
+ if (orientation === "pointy") {
1362
+ const hexW = Math.sqrt(3) * cellSize;
1363
+ const rowH = 1.5 * cellSize;
1364
+ const startRow = Math.floor((bounds.minY - cellSize) / rowH);
1365
+ const endRow = Math.ceil((bounds.maxY + cellSize) / rowH);
1366
+ const startCol = Math.floor((bounds.minX - hexW) / hexW);
1367
+ const endCol = Math.ceil((bounds.maxX + hexW) / hexW);
1368
+ for (let row = startRow; row <= endRow; row++) {
1369
+ const offX = row % 2 !== 0 ? hexW / 2 : 0;
1370
+ for (let col = startCol; col <= endCol; col++) {
1371
+ const cx = col * hexW + offX;
1372
+ const cy = row * rowH;
1373
+ ctx.moveTo(cx + ox0, cy + oy0);
1374
+ ctx.lineTo(cx + ox1, cy + oy1);
1375
+ ctx.lineTo(cx + ox2, cy + oy2);
1376
+ ctx.lineTo(cx + ox3, cy + oy3);
1377
+ ctx.lineTo(cx + ox4, cy + oy4);
1378
+ ctx.lineTo(cx + ox5, cy + oy5);
1379
+ ctx.closePath();
1380
+ }
1381
+ }
1382
+ } else {
1383
+ const hexH = Math.sqrt(3) * cellSize;
1384
+ const colW = 1.5 * cellSize;
1385
+ const startCol = Math.floor((bounds.minX - cellSize) / colW);
1386
+ const endCol = Math.ceil((bounds.maxX + cellSize) / colW);
1387
+ const startRow = Math.floor((bounds.minY - hexH) / hexH);
1388
+ const endRow = Math.ceil((bounds.maxY + hexH) / hexH);
1389
+ for (let col = startCol; col <= endCol; col++) {
1390
+ const offY = col % 2 !== 0 ? hexH / 2 : 0;
1391
+ for (let row = startRow; row <= endRow; row++) {
1392
+ const cx = col * colW;
1393
+ const cy = row * hexH + offY;
1394
+ ctx.moveTo(cx + ox0, cy + oy0);
1395
+ ctx.lineTo(cx + ox1, cy + oy1);
1396
+ ctx.lineTo(cx + ox2, cy + oy2);
1397
+ ctx.lineTo(cx + ox3, cy + oy3);
1398
+ ctx.lineTo(cx + ox4, cy + oy4);
1399
+ ctx.lineTo(cx + ox5, cy + oy5);
1400
+ ctx.closePath();
1401
+ }
1410
1402
  }
1411
- ctx.closePath();
1412
1403
  }
1413
1404
  ctx.stroke();
1414
1405
  ctx.restore();
1415
1406
  }
1407
+ function createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1408
+ let tileW;
1409
+ let tileH;
1410
+ if (orientation === "pointy") {
1411
+ tileW = Math.sqrt(3) * cellSize;
1412
+ tileH = 3 * cellSize;
1413
+ } else {
1414
+ tileW = 3 * cellSize;
1415
+ tileH = Math.sqrt(3) * cellSize;
1416
+ }
1417
+ const pxW = Math.ceil(tileW * scale);
1418
+ const pxH = Math.ceil(tileH * scale);
1419
+ if (pxW <= 0 || pxH <= 0) return null;
1420
+ let canvas;
1421
+ if (typeof OffscreenCanvas !== "undefined") {
1422
+ canvas = new OffscreenCanvas(pxW, pxH);
1423
+ } else if (typeof document !== "undefined") {
1424
+ const el = document.createElement("canvas");
1425
+ el.width = pxW;
1426
+ el.height = pxH;
1427
+ canvas = el;
1428
+ } else {
1429
+ return null;
1430
+ }
1431
+ const tc = canvas.getContext("2d");
1432
+ if (!tc) return null;
1433
+ tc.scale(scale, scale);
1434
+ tc.beginPath();
1435
+ tc.rect(0, 0, tileW, tileH);
1436
+ tc.clip();
1437
+ const angleOffset = orientation === "pointy" ? -Math.PI / 2 : 0;
1438
+ const ox0 = cellSize * Math.cos(angleOffset);
1439
+ const oy0 = cellSize * Math.sin(angleOffset);
1440
+ const ox1 = cellSize * Math.cos(Math.PI / 3 + angleOffset);
1441
+ const oy1 = cellSize * Math.sin(Math.PI / 3 + angleOffset);
1442
+ const ox2 = cellSize * Math.cos(2 * Math.PI / 3 + angleOffset);
1443
+ const oy2 = cellSize * Math.sin(2 * Math.PI / 3 + angleOffset);
1444
+ const ox3 = cellSize * Math.cos(Math.PI + angleOffset);
1445
+ const oy3 = cellSize * Math.sin(Math.PI + angleOffset);
1446
+ const ox4 = cellSize * Math.cos(4 * Math.PI / 3 + angleOffset);
1447
+ const oy4 = cellSize * Math.sin(4 * Math.PI / 3 + angleOffset);
1448
+ const ox5 = cellSize * Math.cos(5 * Math.PI / 3 + angleOffset);
1449
+ const oy5 = cellSize * Math.sin(5 * Math.PI / 3 + angleOffset);
1450
+ tc.strokeStyle = strokeColor;
1451
+ tc.lineWidth = strokeWidth;
1452
+ tc.globalAlpha = opacity;
1453
+ tc.beginPath();
1454
+ if (orientation === "pointy") {
1455
+ const hexW = tileW;
1456
+ const rowH = 1.5 * cellSize;
1457
+ for (let row = -1; row <= 3; row++) {
1458
+ const offX = row % 2 !== 0 ? hexW / 2 : 0;
1459
+ for (let col = -1; col <= 1; col++) {
1460
+ const cx = col * hexW + offX;
1461
+ const cy = row * rowH;
1462
+ tc.moveTo(cx + ox0, cy + oy0);
1463
+ tc.lineTo(cx + ox1, cy + oy1);
1464
+ tc.lineTo(cx + ox2, cy + oy2);
1465
+ tc.lineTo(cx + ox3, cy + oy3);
1466
+ tc.lineTo(cx + ox4, cy + oy4);
1467
+ tc.lineTo(cx + ox5, cy + oy5);
1468
+ tc.closePath();
1469
+ }
1470
+ }
1471
+ } else {
1472
+ const hexH = tileH;
1473
+ const colW = 1.5 * cellSize;
1474
+ for (let col = -1; col <= 3; col++) {
1475
+ const offY = col % 2 !== 0 ? hexH / 2 : 0;
1476
+ for (let row = -1; row <= 1; row++) {
1477
+ const cx = col * colW;
1478
+ const cy = row * hexH + offY;
1479
+ tc.moveTo(cx + ox0, cy + oy0);
1480
+ tc.lineTo(cx + ox1, cy + oy1);
1481
+ tc.lineTo(cx + ox2, cy + oy2);
1482
+ tc.lineTo(cx + ox3, cy + oy3);
1483
+ tc.lineTo(cx + ox4, cy + oy4);
1484
+ tc.lineTo(cx + ox5, cy + oy5);
1485
+ tc.closePath();
1486
+ }
1487
+ }
1488
+ }
1489
+ tc.stroke();
1490
+ return { canvas, tileW, tileH };
1491
+ }
1492
+ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1493
+ const { tileW, tileH } = tile;
1494
+ const startCol = Math.floor(bounds.minX / tileW) - 1;
1495
+ const endCol = Math.ceil(bounds.maxX / tileW) + 1;
1496
+ const startRow = Math.floor(bounds.minY / tileH) - 1;
1497
+ const endRow = Math.ceil(bounds.maxY / tileH) + 1;
1498
+ for (let row = startRow; row <= endRow; row++) {
1499
+ for (let col = startCol; col <= endCol; col++) {
1500
+ ctx.drawImage(tile.canvas, col * tileW, row * tileH, tileW, tileH);
1501
+ }
1502
+ }
1503
+ }
1416
1504
 
1417
1505
  // src/elements/element-renderer.ts
1418
1506
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
@@ -1424,6 +1512,8 @@ var ElementRenderer = class {
1424
1512
  onImageLoad = null;
1425
1513
  camera = null;
1426
1514
  canvasSize = null;
1515
+ hexTileCache = null;
1516
+ hexTileCacheKey = "";
1427
1517
  setStore(store) {
1428
1518
  this.store = store;
1429
1519
  }
@@ -1609,15 +1699,29 @@ var ElementRenderer = class {
1609
1699
  maxY: bottomRight.y
1610
1700
  };
1611
1701
  if (grid.gridType === "hex") {
1612
- renderHexGrid(
1613
- ctx,
1614
- bounds,
1702
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1703
+ const scale = cam.zoom * dpr;
1704
+ const tile = this.getHexTile(
1615
1705
  grid.cellSize,
1616
1706
  grid.hexOrientation,
1617
1707
  grid.strokeColor,
1618
1708
  grid.strokeWidth,
1619
- grid.opacity
1709
+ grid.opacity,
1710
+ scale
1620
1711
  );
1712
+ if (tile) {
1713
+ renderHexGridTiled(ctx, bounds, grid.cellSize, tile);
1714
+ } else {
1715
+ renderHexGrid(
1716
+ ctx,
1717
+ bounds,
1718
+ grid.cellSize,
1719
+ grid.hexOrientation,
1720
+ grid.strokeColor,
1721
+ grid.strokeWidth,
1722
+ grid.opacity
1723
+ );
1724
+ }
1621
1725
  } else {
1622
1726
  renderSquareGrid(
1623
1727
  ctx,
@@ -1640,6 +1744,18 @@ var ElementRenderer = class {
1640
1744
  image.size.h
1641
1745
  );
1642
1746
  }
1747
+ getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1748
+ const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
1749
+ if (this.hexTileCacheKey === key && this.hexTileCache) {
1750
+ return this.hexTileCache;
1751
+ }
1752
+ const tile = createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale);
1753
+ if (tile) {
1754
+ this.hexTileCache = tile;
1755
+ this.hexTileCacheKey = key;
1756
+ }
1757
+ return tile;
1758
+ }
1643
1759
  getImage(src) {
1644
1760
  const cached = this.imageCache.get(src);
1645
1761
  if (cached) {
@@ -2740,17 +2856,19 @@ var SAMPLE_SIZE = 60;
2740
2856
  var RenderStats = class {
2741
2857
  frameTimes = [];
2742
2858
  frameCount = 0;
2743
- recordFrame(durationMs) {
2859
+ _lastGridMs = 0;
2860
+ recordFrame(durationMs, gridMs) {
2744
2861
  this.frameCount++;
2745
2862
  this.frameTimes.push(durationMs);
2746
2863
  if (this.frameTimes.length > SAMPLE_SIZE) {
2747
2864
  this.frameTimes.shift();
2748
2865
  }
2866
+ if (gridMs !== void 0) this._lastGridMs = gridMs;
2749
2867
  }
2750
2868
  getSnapshot() {
2751
2869
  const times = this.frameTimes;
2752
2870
  if (times.length === 0) {
2753
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2871
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
2754
2872
  }
2755
2873
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
2756
2874
  const sorted = [...times].sort((a, b) => a - b);
@@ -2761,6 +2879,7 @@ var RenderStats = class {
2761
2879
  avgFrameMs: Math.round(avg * 100) / 100,
2762
2880
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2763
2881
  lastFrameMs: Math.round(lastFrame * 100) / 100,
2882
+ lastGridMs: Math.round(this._lastGridMs * 100) / 100,
2764
2883
  frameCount: this.frameCount
2765
2884
  };
2766
2885
  }
@@ -2788,6 +2907,15 @@ var RenderLoop = class {
2788
2907
  lastCamX;
2789
2908
  lastCamY;
2790
2909
  stats = new RenderStats();
2910
+ lastGridMs = 0;
2911
+ gridCacheCanvas = null;
2912
+ gridCacheCtx = null;
2913
+ gridCacheZoom = -1;
2914
+ gridCacheCamX = -Infinity;
2915
+ gridCacheCamY = -Infinity;
2916
+ gridCacheWidth = 0;
2917
+ gridCacheHeight = 0;
2918
+ lastGridRef = null;
2791
2919
  constructor(deps) {
2792
2920
  this.canvasEl = deps.canvasEl;
2793
2921
  this.camera = deps.camera;
@@ -2850,6 +2978,29 @@ var RenderLoop = class {
2850
2978
  ctx.drawImage(cached, 0, 0);
2851
2979
  ctx.restore();
2852
2980
  }
2981
+ ensureGridCache(cssWidth, cssHeight, dpr) {
2982
+ if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
2983
+ return;
2984
+ }
2985
+ const physWidth = Math.round(cssWidth * dpr);
2986
+ const physHeight = Math.round(cssHeight * dpr);
2987
+ if (typeof OffscreenCanvas !== "undefined") {
2988
+ this.gridCacheCanvas = new OffscreenCanvas(
2989
+ physWidth,
2990
+ physHeight
2991
+ );
2992
+ } else if (typeof document !== "undefined") {
2993
+ const el = document.createElement("canvas");
2994
+ el.width = physWidth;
2995
+ el.height = physHeight;
2996
+ this.gridCacheCanvas = el;
2997
+ } else {
2998
+ this.gridCacheCanvas = null;
2999
+ this.gridCacheCtx = null;
3000
+ return;
3001
+ }
3002
+ this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
3003
+ }
2853
3004
  render() {
2854
3005
  const t0 = performance.now();
2855
3006
  const ctx = this.canvasEl.getContext("2d");
@@ -2912,9 +3063,6 @@ var RenderLoop = class {
2912
3063
  }
2913
3064
  group.push(element);
2914
3065
  }
2915
- for (const grid of gridElements) {
2916
- this.renderer.renderCanvasElement(ctx, grid);
2917
- }
2918
3066
  for (const [layerId, elements] of layerElements) {
2919
3067
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
2920
3068
  if (!this.layerCache.isDirty(layerId)) {
@@ -2943,13 +3091,53 @@ var RenderLoop = class {
2943
3091
  this.compositeLayerCache(ctx, layerId, dpr);
2944
3092
  }
2945
3093
  }
3094
+ if (gridElements.length > 0) {
3095
+ const gridT0 = performance.now();
3096
+ const gridRef = gridElements[0];
3097
+ const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
3098
+ if (gridCacheHit) {
3099
+ ctx.save();
3100
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3101
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3102
+ ctx.restore();
3103
+ } else {
3104
+ this.ensureGridCache(cssWidth, cssHeight, dpr);
3105
+ if (this.gridCacheCtx && this.gridCacheCanvas) {
3106
+ const gc = this.gridCacheCtx;
3107
+ gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
3108
+ gc.save();
3109
+ gc.scale(dpr, dpr);
3110
+ gc.translate(currentCamX, currentCamY);
3111
+ gc.scale(currentZoom, currentZoom);
3112
+ for (const grid of gridElements) {
3113
+ this.renderer.renderCanvasElement(gc, grid);
3114
+ }
3115
+ gc.restore();
3116
+ ctx.save();
3117
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
3118
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3119
+ ctx.restore();
3120
+ } else {
3121
+ for (const grid of gridElements) {
3122
+ this.renderer.renderCanvasElement(ctx, grid);
3123
+ }
3124
+ }
3125
+ this.gridCacheZoom = currentZoom;
3126
+ this.gridCacheCamX = currentCamX;
3127
+ this.gridCacheCamY = currentCamY;
3128
+ this.gridCacheWidth = cssWidth;
3129
+ this.gridCacheHeight = cssHeight;
3130
+ this.lastGridRef = gridRef;
3131
+ }
3132
+ this.lastGridMs = performance.now() - gridT0;
3133
+ }
2946
3134
  const activeTool = this.toolManager.activeTool;
2947
3135
  if (activeTool?.renderOverlay) {
2948
3136
  activeTool.renderOverlay(ctx);
2949
3137
  }
2950
3138
  ctx.restore();
2951
3139
  ctx.restore();
2952
- this.stats.recordFrame(performance.now() - t0);
3140
+ this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
2953
3141
  }
2954
3142
  };
2955
3143
 
@@ -3249,6 +3437,18 @@ var Viewport = class {
3249
3437
  this.historyRecorder.commit();
3250
3438
  this.requestRender();
3251
3439
  }
3440
+ getRenderStats() {
3441
+ return this.renderLoop.getStats();
3442
+ }
3443
+ logPerformance(intervalMs = 2e3) {
3444
+ const id = setInterval(() => {
3445
+ const s = this.getRenderStats();
3446
+ console.log(
3447
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
3448
+ );
3449
+ }, intervalMs);
3450
+ return () => clearInterval(id);
3451
+ }
3252
3452
  destroy() {
3253
3453
  this.renderLoop.stop();
3254
3454
  this.interactMode.destroy();
@@ -4633,7 +4833,7 @@ var UpdateLayerCommand = class {
4633
4833
  };
4634
4834
 
4635
4835
  // src/index.ts
4636
- var VERSION = "0.8.8";
4836
+ var VERSION = "0.8.10";
4637
4837
  export {
4638
4838
  AddElementCommand,
4639
4839
  ArrowTool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/core",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "Vanilla TypeScript infinite canvas engine",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",