@fieldnotes/core 0.8.8 → 0.8.9

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,172 @@ 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, scale) {
1578
+ const pattern = ctx.createPattern(tile.canvas, "repeat");
1579
+ if (!pattern) return;
1580
+ const mat = new DOMMatrix();
1581
+ mat.scaleSelf(1 / scale, 1 / scale);
1582
+ pattern.setTransform(mat);
1583
+ ctx.save();
1584
+ ctx.fillStyle = pattern;
1585
+ const pad = cellSize * 2;
1586
+ ctx.fillRect(
1587
+ bounds.minX - pad,
1588
+ bounds.minY - pad,
1589
+ bounds.maxX - bounds.minX + pad * 2,
1590
+ bounds.maxY - bounds.minY + pad * 2
1591
+ );
1592
+ ctx.restore();
1593
+ }
1501
1594
 
1502
1595
  // src/elements/element-renderer.ts
1503
1596
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
@@ -1509,6 +1602,8 @@ var ElementRenderer = class {
1509
1602
  onImageLoad = null;
1510
1603
  camera = null;
1511
1604
  canvasSize = null;
1605
+ hexTileCache = null;
1606
+ hexTileCacheKey = "";
1512
1607
  setStore(store) {
1513
1608
  this.store = store;
1514
1609
  }
@@ -1694,15 +1789,29 @@ var ElementRenderer = class {
1694
1789
  maxY: bottomRight.y
1695
1790
  };
1696
1791
  if (grid.gridType === "hex") {
1697
- renderHexGrid(
1698
- ctx,
1699
- bounds,
1792
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1793
+ const scale = cam.zoom * dpr;
1794
+ const tile = this.getHexTile(
1700
1795
  grid.cellSize,
1701
1796
  grid.hexOrientation,
1702
1797
  grid.strokeColor,
1703
1798
  grid.strokeWidth,
1704
- grid.opacity
1799
+ grid.opacity,
1800
+ scale
1705
1801
  );
1802
+ if (tile) {
1803
+ renderHexGridTiled(ctx, bounds, grid.cellSize, tile, scale);
1804
+ } else {
1805
+ renderHexGrid(
1806
+ ctx,
1807
+ bounds,
1808
+ grid.cellSize,
1809
+ grid.hexOrientation,
1810
+ grid.strokeColor,
1811
+ grid.strokeWidth,
1812
+ grid.opacity
1813
+ );
1814
+ }
1706
1815
  } else {
1707
1816
  renderSquareGrid(
1708
1817
  ctx,
@@ -1725,6 +1834,18 @@ var ElementRenderer = class {
1725
1834
  image.size.h
1726
1835
  );
1727
1836
  }
1837
+ getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1838
+ const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
1839
+ if (this.hexTileCacheKey === key && this.hexTileCache) {
1840
+ return this.hexTileCache;
1841
+ }
1842
+ const tile = createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale);
1843
+ if (tile) {
1844
+ this.hexTileCache = tile;
1845
+ this.hexTileCacheKey = key;
1846
+ }
1847
+ return tile;
1848
+ }
1728
1849
  getImage(src) {
1729
1850
  const cached = this.imageCache.get(src);
1730
1851
  if (cached) {
@@ -2825,17 +2946,19 @@ var SAMPLE_SIZE = 60;
2825
2946
  var RenderStats = class {
2826
2947
  frameTimes = [];
2827
2948
  frameCount = 0;
2828
- recordFrame(durationMs) {
2949
+ _lastGridMs = 0;
2950
+ recordFrame(durationMs, gridMs) {
2829
2951
  this.frameCount++;
2830
2952
  this.frameTimes.push(durationMs);
2831
2953
  if (this.frameTimes.length > SAMPLE_SIZE) {
2832
2954
  this.frameTimes.shift();
2833
2955
  }
2956
+ if (gridMs !== void 0) this._lastGridMs = gridMs;
2834
2957
  }
2835
2958
  getSnapshot() {
2836
2959
  const times = this.frameTimes;
2837
2960
  if (times.length === 0) {
2838
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2961
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
2839
2962
  }
2840
2963
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
2841
2964
  const sorted = [...times].sort((a, b) => a - b);
@@ -2846,6 +2969,7 @@ var RenderStats = class {
2846
2969
  avgFrameMs: Math.round(avg * 100) / 100,
2847
2970
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2848
2971
  lastFrameMs: Math.round(lastFrame * 100) / 100,
2972
+ lastGridMs: Math.round(this._lastGridMs * 100) / 100,
2849
2973
  frameCount: this.frameCount
2850
2974
  };
2851
2975
  }
@@ -2873,6 +2997,15 @@ var RenderLoop = class {
2873
2997
  lastCamX;
2874
2998
  lastCamY;
2875
2999
  stats = new RenderStats();
3000
+ lastGridMs = 0;
3001
+ gridCacheCanvas = null;
3002
+ gridCacheCtx = null;
3003
+ gridCacheZoom = -1;
3004
+ gridCacheCamX = -Infinity;
3005
+ gridCacheCamY = -Infinity;
3006
+ gridCacheWidth = 0;
3007
+ gridCacheHeight = 0;
3008
+ lastGridRef = null;
2876
3009
  constructor(deps) {
2877
3010
  this.canvasEl = deps.canvasEl;
2878
3011
  this.camera = deps.camera;
@@ -2935,6 +3068,29 @@ var RenderLoop = class {
2935
3068
  ctx.drawImage(cached, 0, 0);
2936
3069
  ctx.restore();
2937
3070
  }
3071
+ ensureGridCache(cssWidth, cssHeight, dpr) {
3072
+ if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
3073
+ return;
3074
+ }
3075
+ const physWidth = Math.round(cssWidth * dpr);
3076
+ const physHeight = Math.round(cssHeight * dpr);
3077
+ if (typeof OffscreenCanvas !== "undefined") {
3078
+ this.gridCacheCanvas = new OffscreenCanvas(
3079
+ physWidth,
3080
+ physHeight
3081
+ );
3082
+ } else if (typeof document !== "undefined") {
3083
+ const el = document.createElement("canvas");
3084
+ el.width = physWidth;
3085
+ el.height = physHeight;
3086
+ this.gridCacheCanvas = el;
3087
+ } else {
3088
+ this.gridCacheCanvas = null;
3089
+ this.gridCacheCtx = null;
3090
+ return;
3091
+ }
3092
+ this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
3093
+ }
2938
3094
  render() {
2939
3095
  const t0 = performance.now();
2940
3096
  const ctx = this.canvasEl.getContext("2d");
@@ -2997,9 +3153,6 @@ var RenderLoop = class {
2997
3153
  }
2998
3154
  group.push(element);
2999
3155
  }
3000
- for (const grid of gridElements) {
3001
- this.renderer.renderCanvasElement(ctx, grid);
3002
- }
3003
3156
  for (const [layerId, elements] of layerElements) {
3004
3157
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
3005
3158
  if (!this.layerCache.isDirty(layerId)) {
@@ -3028,13 +3181,53 @@ var RenderLoop = class {
3028
3181
  this.compositeLayerCache(ctx, layerId, dpr);
3029
3182
  }
3030
3183
  }
3184
+ if (gridElements.length > 0) {
3185
+ const gridT0 = performance.now();
3186
+ const gridRef = gridElements[0];
3187
+ const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
3188
+ if (gridCacheHit) {
3189
+ ctx.save();
3190
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3191
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3192
+ ctx.restore();
3193
+ } else {
3194
+ this.ensureGridCache(cssWidth, cssHeight, dpr);
3195
+ if (this.gridCacheCtx && this.gridCacheCanvas) {
3196
+ const gc = this.gridCacheCtx;
3197
+ gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
3198
+ gc.save();
3199
+ gc.scale(dpr, dpr);
3200
+ gc.translate(currentCamX, currentCamY);
3201
+ gc.scale(currentZoom, currentZoom);
3202
+ for (const grid of gridElements) {
3203
+ this.renderer.renderCanvasElement(gc, grid);
3204
+ }
3205
+ gc.restore();
3206
+ ctx.save();
3207
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3208
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3209
+ ctx.restore();
3210
+ } else {
3211
+ for (const grid of gridElements) {
3212
+ this.renderer.renderCanvasElement(ctx, grid);
3213
+ }
3214
+ }
3215
+ this.gridCacheZoom = currentZoom;
3216
+ this.gridCacheCamX = currentCamX;
3217
+ this.gridCacheCamY = currentCamY;
3218
+ this.gridCacheWidth = cssWidth;
3219
+ this.gridCacheHeight = cssHeight;
3220
+ this.lastGridRef = gridRef;
3221
+ }
3222
+ this.lastGridMs = performance.now() - gridT0;
3223
+ }
3031
3224
  const activeTool = this.toolManager.activeTool;
3032
3225
  if (activeTool?.renderOverlay) {
3033
3226
  activeTool.renderOverlay(ctx);
3034
3227
  }
3035
3228
  ctx.restore();
3036
3229
  ctx.restore();
3037
- this.stats.recordFrame(performance.now() - t0);
3230
+ this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
3038
3231
  }
3039
3232
  };
3040
3233
 
@@ -3334,6 +3527,18 @@ var Viewport = class {
3334
3527
  this.historyRecorder.commit();
3335
3528
  this.requestRender();
3336
3529
  }
3530
+ getRenderStats() {
3531
+ return this.renderLoop.getStats();
3532
+ }
3533
+ logPerformance(intervalMs = 2e3) {
3534
+ const id = setInterval(() => {
3535
+ const s = this.getRenderStats();
3536
+ console.log(
3537
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
3538
+ );
3539
+ }, intervalMs);
3540
+ return () => clearInterval(id);
3541
+ }
3337
3542
  destroy() {
3338
3543
  this.renderLoop.stop();
3339
3544
  this.interactMode.destroy();
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
 
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
 
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,172 @@ 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, scale) {
1493
+ const pattern = ctx.createPattern(tile.canvas, "repeat");
1494
+ if (!pattern) return;
1495
+ const mat = new DOMMatrix();
1496
+ mat.scaleSelf(1 / scale, 1 / scale);
1497
+ pattern.setTransform(mat);
1498
+ ctx.save();
1499
+ ctx.fillStyle = pattern;
1500
+ const pad = cellSize * 2;
1501
+ ctx.fillRect(
1502
+ bounds.minX - pad,
1503
+ bounds.minY - pad,
1504
+ bounds.maxX - bounds.minX + pad * 2,
1505
+ bounds.maxY - bounds.minY + pad * 2
1506
+ );
1507
+ ctx.restore();
1508
+ }
1416
1509
 
1417
1510
  // src/elements/element-renderer.ts
1418
1511
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
@@ -1424,6 +1517,8 @@ var ElementRenderer = class {
1424
1517
  onImageLoad = null;
1425
1518
  camera = null;
1426
1519
  canvasSize = null;
1520
+ hexTileCache = null;
1521
+ hexTileCacheKey = "";
1427
1522
  setStore(store) {
1428
1523
  this.store = store;
1429
1524
  }
@@ -1609,15 +1704,29 @@ var ElementRenderer = class {
1609
1704
  maxY: bottomRight.y
1610
1705
  };
1611
1706
  if (grid.gridType === "hex") {
1612
- renderHexGrid(
1613
- ctx,
1614
- bounds,
1707
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1708
+ const scale = cam.zoom * dpr;
1709
+ const tile = this.getHexTile(
1615
1710
  grid.cellSize,
1616
1711
  grid.hexOrientation,
1617
1712
  grid.strokeColor,
1618
1713
  grid.strokeWidth,
1619
- grid.opacity
1714
+ grid.opacity,
1715
+ scale
1620
1716
  );
1717
+ if (tile) {
1718
+ renderHexGridTiled(ctx, bounds, grid.cellSize, tile, scale);
1719
+ } else {
1720
+ renderHexGrid(
1721
+ ctx,
1722
+ bounds,
1723
+ grid.cellSize,
1724
+ grid.hexOrientation,
1725
+ grid.strokeColor,
1726
+ grid.strokeWidth,
1727
+ grid.opacity
1728
+ );
1729
+ }
1621
1730
  } else {
1622
1731
  renderSquareGrid(
1623
1732
  ctx,
@@ -1640,6 +1749,18 @@ var ElementRenderer = class {
1640
1749
  image.size.h
1641
1750
  );
1642
1751
  }
1752
+ getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
1753
+ const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
1754
+ if (this.hexTileCacheKey === key && this.hexTileCache) {
1755
+ return this.hexTileCache;
1756
+ }
1757
+ const tile = createHexGridTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale);
1758
+ if (tile) {
1759
+ this.hexTileCache = tile;
1760
+ this.hexTileCacheKey = key;
1761
+ }
1762
+ return tile;
1763
+ }
1643
1764
  getImage(src) {
1644
1765
  const cached = this.imageCache.get(src);
1645
1766
  if (cached) {
@@ -2740,17 +2861,19 @@ var SAMPLE_SIZE = 60;
2740
2861
  var RenderStats = class {
2741
2862
  frameTimes = [];
2742
2863
  frameCount = 0;
2743
- recordFrame(durationMs) {
2864
+ _lastGridMs = 0;
2865
+ recordFrame(durationMs, gridMs) {
2744
2866
  this.frameCount++;
2745
2867
  this.frameTimes.push(durationMs);
2746
2868
  if (this.frameTimes.length > SAMPLE_SIZE) {
2747
2869
  this.frameTimes.shift();
2748
2870
  }
2871
+ if (gridMs !== void 0) this._lastGridMs = gridMs;
2749
2872
  }
2750
2873
  getSnapshot() {
2751
2874
  const times = this.frameTimes;
2752
2875
  if (times.length === 0) {
2753
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, frameCount: 0 };
2876
+ return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
2754
2877
  }
2755
2878
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
2756
2879
  const sorted = [...times].sort((a, b) => a - b);
@@ -2761,6 +2884,7 @@ var RenderStats = class {
2761
2884
  avgFrameMs: Math.round(avg * 100) / 100,
2762
2885
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
2763
2886
  lastFrameMs: Math.round(lastFrame * 100) / 100,
2887
+ lastGridMs: Math.round(this._lastGridMs * 100) / 100,
2764
2888
  frameCount: this.frameCount
2765
2889
  };
2766
2890
  }
@@ -2788,6 +2912,15 @@ var RenderLoop = class {
2788
2912
  lastCamX;
2789
2913
  lastCamY;
2790
2914
  stats = new RenderStats();
2915
+ lastGridMs = 0;
2916
+ gridCacheCanvas = null;
2917
+ gridCacheCtx = null;
2918
+ gridCacheZoom = -1;
2919
+ gridCacheCamX = -Infinity;
2920
+ gridCacheCamY = -Infinity;
2921
+ gridCacheWidth = 0;
2922
+ gridCacheHeight = 0;
2923
+ lastGridRef = null;
2791
2924
  constructor(deps) {
2792
2925
  this.canvasEl = deps.canvasEl;
2793
2926
  this.camera = deps.camera;
@@ -2850,6 +2983,29 @@ var RenderLoop = class {
2850
2983
  ctx.drawImage(cached, 0, 0);
2851
2984
  ctx.restore();
2852
2985
  }
2986
+ ensureGridCache(cssWidth, cssHeight, dpr) {
2987
+ if (this.gridCacheCanvas !== null && this.gridCacheWidth === cssWidth && this.gridCacheHeight === cssHeight) {
2988
+ return;
2989
+ }
2990
+ const physWidth = Math.round(cssWidth * dpr);
2991
+ const physHeight = Math.round(cssHeight * dpr);
2992
+ if (typeof OffscreenCanvas !== "undefined") {
2993
+ this.gridCacheCanvas = new OffscreenCanvas(
2994
+ physWidth,
2995
+ physHeight
2996
+ );
2997
+ } else if (typeof document !== "undefined") {
2998
+ const el = document.createElement("canvas");
2999
+ el.width = physWidth;
3000
+ el.height = physHeight;
3001
+ this.gridCacheCanvas = el;
3002
+ } else {
3003
+ this.gridCacheCanvas = null;
3004
+ this.gridCacheCtx = null;
3005
+ return;
3006
+ }
3007
+ this.gridCacheCtx = this.gridCacheCanvas.getContext("2d");
3008
+ }
2853
3009
  render() {
2854
3010
  const t0 = performance.now();
2855
3011
  const ctx = this.canvasEl.getContext("2d");
@@ -2912,9 +3068,6 @@ var RenderLoop = class {
2912
3068
  }
2913
3069
  group.push(element);
2914
3070
  }
2915
- for (const grid of gridElements) {
2916
- this.renderer.renderCanvasElement(ctx, grid);
2917
- }
2918
3071
  for (const [layerId, elements] of layerElements) {
2919
3072
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
2920
3073
  if (!this.layerCache.isDirty(layerId)) {
@@ -2943,13 +3096,53 @@ var RenderLoop = class {
2943
3096
  this.compositeLayerCache(ctx, layerId, dpr);
2944
3097
  }
2945
3098
  }
3099
+ if (gridElements.length > 0) {
3100
+ const gridT0 = performance.now();
3101
+ const gridRef = gridElements[0];
3102
+ const gridCacheHit = this.gridCacheCanvas !== null && currentZoom === this.gridCacheZoom && currentCamX === this.gridCacheCamX && currentCamY === this.gridCacheCamY && cssWidth === this.gridCacheWidth && cssHeight === this.gridCacheHeight && gridRef === this.lastGridRef;
3103
+ if (gridCacheHit) {
3104
+ ctx.save();
3105
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3106
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3107
+ ctx.restore();
3108
+ } else {
3109
+ this.ensureGridCache(cssWidth, cssHeight, dpr);
3110
+ if (this.gridCacheCtx && this.gridCacheCanvas) {
3111
+ const gc = this.gridCacheCtx;
3112
+ gc.clearRect(0, 0, this.gridCacheCanvas.width, this.gridCacheCanvas.height);
3113
+ gc.save();
3114
+ gc.scale(dpr, dpr);
3115
+ gc.translate(currentCamX, currentCamY);
3116
+ gc.scale(currentZoom, currentZoom);
3117
+ for (const grid of gridElements) {
3118
+ this.renderer.renderCanvasElement(gc, grid);
3119
+ }
3120
+ gc.restore();
3121
+ ctx.save();
3122
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3123
+ ctx.drawImage(this.gridCacheCanvas, 0, 0);
3124
+ ctx.restore();
3125
+ } else {
3126
+ for (const grid of gridElements) {
3127
+ this.renderer.renderCanvasElement(ctx, grid);
3128
+ }
3129
+ }
3130
+ this.gridCacheZoom = currentZoom;
3131
+ this.gridCacheCamX = currentCamX;
3132
+ this.gridCacheCamY = currentCamY;
3133
+ this.gridCacheWidth = cssWidth;
3134
+ this.gridCacheHeight = cssHeight;
3135
+ this.lastGridRef = gridRef;
3136
+ }
3137
+ this.lastGridMs = performance.now() - gridT0;
3138
+ }
2946
3139
  const activeTool = this.toolManager.activeTool;
2947
3140
  if (activeTool?.renderOverlay) {
2948
3141
  activeTool.renderOverlay(ctx);
2949
3142
  }
2950
3143
  ctx.restore();
2951
3144
  ctx.restore();
2952
- this.stats.recordFrame(performance.now() - t0);
3145
+ this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
2953
3146
  }
2954
3147
  };
2955
3148
 
@@ -3249,6 +3442,18 @@ var Viewport = class {
3249
3442
  this.historyRecorder.commit();
3250
3443
  this.requestRender();
3251
3444
  }
3445
+ getRenderStats() {
3446
+ return this.renderLoop.getStats();
3447
+ }
3448
+ logPerformance(intervalMs = 2e3) {
3449
+ const id = setInterval(() => {
3450
+ const s = this.getRenderStats();
3451
+ console.log(
3452
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
3453
+ );
3454
+ }, intervalMs);
3455
+ return () => clearInterval(id);
3456
+ }
3252
3457
  destroy() {
3253
3458
  this.renderLoop.stop();
3254
3459
  this.interactMode.destroy();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fieldnotes/core",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "Vanilla TypeScript infinite canvas engine",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",