@fieldnotes/core 0.8.10 → 0.9.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
@@ -37,6 +37,7 @@ __export(index_exports, {
37
37
  ImageTool: () => ImageTool,
38
38
  InputHandler: () => InputHandler,
39
39
  LayerManager: () => LayerManager,
40
+ MeasureTool: () => MeasureTool,
40
41
  NoteEditor: () => NoteEditor,
41
42
  NoteTool: () => NoteTool,
42
43
  PencilTool: () => PencilTool,
@@ -45,6 +46,7 @@ __export(index_exports, {
45
46
  RemoveLayerCommand: () => RemoveLayerCommand,
46
47
  SelectTool: () => SelectTool,
47
48
  ShapeTool: () => ShapeTool,
49
+ TemplateTool: () => TemplateTool,
48
50
  TextTool: () => TextTool,
49
51
  ToolManager: () => ToolManager,
50
52
  UpdateElementCommand: () => UpdateElementCommand,
@@ -61,7 +63,9 @@ __export(index_exports, {
61
63
  createNote: () => createNote,
62
64
  createShape: () => createShape,
63
65
  createStroke: () => createStroke,
66
+ createTemplate: () => createTemplate,
64
67
  createText: () => createText,
68
+ drawHexPath: () => drawHexPath,
65
69
  exportImage: () => exportImage,
66
70
  exportState: () => exportState,
67
71
  findBindTarget: () => findBindTarget,
@@ -74,10 +78,17 @@ __export(index_exports, {
74
78
  getEdgeIntersection: () => getEdgeIntersection,
75
79
  getElementBounds: () => getElementBounds,
76
80
  getElementCenter: () => getElementCenter,
81
+ getHexCellsInCone: () => getHexCellsInCone,
82
+ getHexCellsInLine: () => getHexCellsInLine,
83
+ getHexCellsInRadius: () => getHexCellsInRadius,
84
+ getHexCellsInSquare: () => getHexCellsInSquare,
85
+ getHexDistance: () => getHexDistance,
77
86
  isBindable: () => isBindable,
78
87
  isNearBezier: () => isNearBezier,
79
88
  parseState: () => parseState,
89
+ smartSnap: () => smartSnap,
80
90
  snapPoint: () => snapPoint,
91
+ snapToHexCenter: () => snapToHexCenter,
81
92
  unbindArrow: () => unbindArrow,
82
93
  updateBoundArrow: () => updateBoundArrow
83
94
  });
@@ -375,6 +386,30 @@ function snapPoint(point, gridSize) {
375
386
  y: Math.round(point.y / gridSize) * gridSize || 0
376
387
  };
377
388
  }
389
+ function snapToHexCenter(point, cellSize, orientation) {
390
+ if (orientation === "pointy") {
391
+ const hexW = Math.sqrt(3) * cellSize;
392
+ const rowH = 1.5 * cellSize;
393
+ const row = Math.round(point.y / rowH);
394
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
395
+ const col = Math.round((point.x - offsetX) / hexW);
396
+ return { x: col * hexW + offsetX || 0, y: row * rowH || 0 };
397
+ } else {
398
+ const hexH = Math.sqrt(3) * cellSize;
399
+ const colW = 1.5 * cellSize;
400
+ const col = Math.round(point.x / colW);
401
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
402
+ const row = Math.round((point.y - offsetY) / hexH);
403
+ return { x: col * colW || 0, y: row * hexH + offsetY || 0 };
404
+ }
405
+ }
406
+ function smartSnap(point, ctx) {
407
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
408
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
409
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
410
+ }
411
+ return snapPoint(point, ctx.gridSize);
412
+ }
378
413
 
379
414
  // src/core/auto-save.ts
380
415
  var DEFAULT_KEY = "fieldnotes-autosave";
@@ -1027,6 +1062,9 @@ function getElementBounds(element) {
1027
1062
  if (element.type === "arrow") {
1028
1063
  return getArrowBoundsAnalytical(element.from, element.to, element.bend);
1029
1064
  }
1065
+ if (element.type === "template") {
1066
+ return getTemplateBounds(element);
1067
+ }
1030
1068
  return null;
1031
1069
  }
1032
1070
  function getArrowBoundsAnalytical(from, to, bend) {
@@ -1067,6 +1105,62 @@ function getArrowBoundsAnalytical(from, to, bend) {
1067
1105
  }
1068
1106
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1069
1107
  }
1108
+ function getTemplateBounds(el) {
1109
+ const { x: cx, y: cy } = el.position;
1110
+ const r = el.radius;
1111
+ switch (el.templateShape) {
1112
+ case "circle":
1113
+ return { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r };
1114
+ case "square":
1115
+ return { x: cx - r / 2, y: cy - r / 2, w: r, h: r };
1116
+ case "cone": {
1117
+ const halfAngle = Math.atan(0.5);
1118
+ const tipX = cx;
1119
+ const tipY = cy;
1120
+ const leftX = cx + r * Math.cos(el.angle - halfAngle);
1121
+ const leftY = cy + r * Math.sin(el.angle - halfAngle);
1122
+ const rightX = cx + r * Math.cos(el.angle + halfAngle);
1123
+ const rightY = cy + r * Math.sin(el.angle + halfAngle);
1124
+ const farX = cx + r * Math.cos(el.angle);
1125
+ const farY = cy + r * Math.sin(el.angle);
1126
+ const xs = [tipX, leftX, rightX, farX];
1127
+ const ys = [tipY, leftY, rightY, farY];
1128
+ let minX = Infinity;
1129
+ let minY = Infinity;
1130
+ let maxX = -Infinity;
1131
+ let maxY = -Infinity;
1132
+ for (let i = 0; i < xs.length; i++) {
1133
+ const px = xs[i];
1134
+ const py = ys[i];
1135
+ if (px !== void 0 && px < minX) minX = px;
1136
+ if (px !== void 0 && px > maxX) maxX = px;
1137
+ if (py !== void 0 && py < minY) minY = py;
1138
+ if (py !== void 0 && py > maxY) maxY = py;
1139
+ }
1140
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1141
+ }
1142
+ case "line": {
1143
+ const halfW = r / 12;
1144
+ const cos = Math.cos(el.angle);
1145
+ const sin = Math.sin(el.angle);
1146
+ const perpX = -sin * halfW;
1147
+ const perpY = cos * halfW;
1148
+ const x0 = cx + perpX;
1149
+ const y0 = cy + perpY;
1150
+ const x1 = cx + r * cos + perpX;
1151
+ const y1 = cy + r * sin + perpY;
1152
+ const x2 = cx + r * cos - perpX;
1153
+ const y2 = cy + r * sin - perpY;
1154
+ const x3 = cx - perpX;
1155
+ const y3 = cy - perpY;
1156
+ const minX = Math.min(x0, x1, x2, x3);
1157
+ const minY = Math.min(y0, y1, y2, y3);
1158
+ const maxX = Math.max(x0, x1, x2, x3);
1159
+ const maxY = Math.max(y0, y1, y2, y3);
1160
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1161
+ }
1162
+ }
1163
+ }
1070
1164
  function boundsIntersect(a, b) {
1071
1165
  return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
1072
1166
  }
@@ -1587,6 +1681,190 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1587
1681
  }
1588
1682
  }
1589
1683
 
1684
+ // src/elements/hex-fill.ts
1685
+ function offsetToCube(col, row, orientation) {
1686
+ if (orientation === "pointy") {
1687
+ return { q: col - (row - (row & 1)) / 2, r: row };
1688
+ }
1689
+ return { q: col, r: row - (col - (col & 1)) / 2 };
1690
+ }
1691
+ function cubeToOffset(q, r, orientation) {
1692
+ if (orientation === "pointy") {
1693
+ return { col: q + (r - (r & 1)) / 2, row: r };
1694
+ }
1695
+ return { col: q, row: r + (q - (q & 1)) / 2 };
1696
+ }
1697
+ function offsetToPixel(col, row, cellSize, orientation) {
1698
+ if (orientation === "pointy") {
1699
+ const hexW = Math.sqrt(3) * cellSize;
1700
+ const rowH = 1.5 * cellSize;
1701
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1702
+ return { x: col * hexW + offsetX, y: row * rowH };
1703
+ }
1704
+ const hexH = Math.sqrt(3) * cellSize;
1705
+ const colW = 1.5 * cellSize;
1706
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1707
+ return { x: col * colW, y: row * hexH + offsetY };
1708
+ }
1709
+ function pixelToOffset(x, y, cellSize, orientation) {
1710
+ if (orientation === "pointy") {
1711
+ const hexW = Math.sqrt(3) * cellSize;
1712
+ const rowH = 1.5 * cellSize;
1713
+ const row = Math.round(y / rowH);
1714
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1715
+ return { col: Math.round((x - offsetX) / hexW), row };
1716
+ }
1717
+ const hexH = Math.sqrt(3) * cellSize;
1718
+ const colW = 1.5 * cellSize;
1719
+ const col = Math.round(x / colW);
1720
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1721
+ return { col, row: Math.round((y - offsetY) / hexH) };
1722
+ }
1723
+ function enumerateHexRing(centerQ, centerR, n, orientation, cellSize) {
1724
+ const cells = [];
1725
+ for (let dq = -n; dq <= n; dq++) {
1726
+ const rMin = Math.max(-n, -dq - n);
1727
+ const rMax = Math.min(n, -dq + n);
1728
+ for (let dr = rMin; dr <= rMax; dr++) {
1729
+ const absQ = centerQ + dq;
1730
+ const absR = centerR + dr;
1731
+ const off = cubeToOffset(absQ, absR, orientation);
1732
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
1733
+ }
1734
+ }
1735
+ return cells;
1736
+ }
1737
+ function getHexDistance(a, b, cellSize, orientation) {
1738
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
1739
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
1740
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
1741
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
1742
+ const dq = cubeA.q - cubeB.q;
1743
+ const dr = cubeA.r - cubeB.r;
1744
+ const ds = -dq - dr;
1745
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
1746
+ }
1747
+ function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
1748
+ const n = Math.round(radiusCells);
1749
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1750
+ const cube = offsetToCube(off.col, off.row, orientation);
1751
+ if (n <= 0) {
1752
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
1753
+ }
1754
+ return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
1755
+ }
1756
+ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
1757
+ const n = Math.round(radiusCells);
1758
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1759
+ const cube = offsetToCube(off.col, off.row, orientation);
1760
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1761
+ if (n <= 0) return [centerPixel];
1762
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1763
+ const step = Math.PI / 3;
1764
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1765
+ const halfAngle = Math.PI / 6 + 1e-6;
1766
+ const cells = [centerPixel];
1767
+ for (let dq = -n; dq <= n; dq++) {
1768
+ const rMin = Math.max(-n, -dq - n);
1769
+ const rMax = Math.min(n, -dq + n);
1770
+ for (let dr = rMin; dr <= rMax; dr++) {
1771
+ if (dq === 0 && dr === 0) continue;
1772
+ const absQ = cube.q + dq;
1773
+ const absR = cube.r + dr;
1774
+ const pixel = offsetToPixel(
1775
+ cubeToOffset(absQ, absR, orientation).col,
1776
+ cubeToOffset(absQ, absR, orientation).row,
1777
+ cellSize,
1778
+ orientation
1779
+ );
1780
+ const dx = pixel.x - centerPixel.x;
1781
+ const dy = pixel.y - centerPixel.y;
1782
+ let diff = Math.atan2(dy, dx) - snappedAngle;
1783
+ if (diff > Math.PI) diff -= 2 * Math.PI;
1784
+ if (diff < -Math.PI) diff += 2 * Math.PI;
1785
+ if (Math.abs(diff) <= halfAngle) {
1786
+ cells.push(pixel);
1787
+ }
1788
+ }
1789
+ }
1790
+ return cells;
1791
+ }
1792
+ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
1793
+ const n = Math.round(radiusCells);
1794
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1795
+ const cube = offsetToCube(off.col, off.row, orientation);
1796
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1797
+ if (n <= 0) return [centerPixel];
1798
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1799
+ const step = Math.PI / 3;
1800
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1801
+ const cos = Math.cos(snappedAngle);
1802
+ const sin = Math.sin(snappedAngle);
1803
+ const snapUnit = Math.sqrt(3) * cellSize;
1804
+ const lineLength = n * snapUnit;
1805
+ const halfWidth = snapUnit * 0.5 + 1e-6;
1806
+ const cells = [];
1807
+ for (let dq = -n; dq <= n; dq++) {
1808
+ const rMin = Math.max(-n, -dq - n);
1809
+ const rMax = Math.min(n, -dq + n);
1810
+ for (let dr = rMin; dr <= rMax; dr++) {
1811
+ const absQ = cube.q + dq;
1812
+ const absR = cube.r + dr;
1813
+ const pixel = offsetToPixel(
1814
+ cubeToOffset(absQ, absR, orientation).col,
1815
+ cubeToOffset(absQ, absR, orientation).row,
1816
+ cellSize,
1817
+ orientation
1818
+ );
1819
+ const dx = pixel.x - centerPixel.x;
1820
+ const dy = pixel.y - centerPixel.y;
1821
+ const along = dx * cos + dy * sin;
1822
+ const perp = Math.abs(-dx * sin + dy * cos);
1823
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
1824
+ cells.push(pixel);
1825
+ }
1826
+ }
1827
+ }
1828
+ return cells;
1829
+ }
1830
+ function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
1831
+ const n = Math.round(radiusCells);
1832
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1833
+ const cube = offsetToCube(off.col, off.row, orientation);
1834
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1835
+ if (n <= 0) return [centerPixel];
1836
+ const snapUnit = Math.sqrt(3) * cellSize;
1837
+ const halfSide = n * snapUnit / 2;
1838
+ const cells = [];
1839
+ for (let dq = -n; dq <= n; dq++) {
1840
+ const rMin = Math.max(-n, -dq - n);
1841
+ const rMax = Math.min(n, -dq + n);
1842
+ for (let dr = rMin; dr <= rMax; dr++) {
1843
+ const absQ = cube.q + dq;
1844
+ const absR = cube.r + dr;
1845
+ const pixel = offsetToPixel(
1846
+ cubeToOffset(absQ, absR, orientation).col,
1847
+ cubeToOffset(absQ, absR, orientation).row,
1848
+ cellSize,
1849
+ orientation
1850
+ );
1851
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
1852
+ cells.push(pixel);
1853
+ }
1854
+ }
1855
+ }
1856
+ return cells;
1857
+ }
1858
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
1859
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1860
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
1861
+ for (let i = 1; i < 6; i++) {
1862
+ const a = angleOffset + Math.PI / 3 * i;
1863
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
1864
+ }
1865
+ ctx.closePath();
1866
+ }
1867
+
1590
1868
  // src/elements/element-renderer.ts
1591
1869
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
1592
1870
  var ARROWHEAD_LENGTH = 12;
@@ -1631,6 +1909,9 @@ var ElementRenderer = class {
1631
1909
  case "grid":
1632
1910
  this.renderGrid(ctx, element);
1633
1911
  break;
1912
+ case "template":
1913
+ this.renderTemplate(ctx, element);
1914
+ break;
1634
1915
  }
1635
1916
  }
1636
1917
  renderStroke(ctx, stroke) {
@@ -1818,6 +2099,147 @@ var ElementRenderer = class {
1818
2099
  );
1819
2100
  }
1820
2101
  }
2102
+ renderTemplate(ctx, template) {
2103
+ const grid = this.store?.getElementsByType("grid")[0];
2104
+ if (grid && grid.gridType === "hex") {
2105
+ this.renderHexTemplate(ctx, template, grid.cellSize, grid.hexOrientation);
2106
+ return;
2107
+ }
2108
+ this.renderGeometricTemplate(ctx, template);
2109
+ }
2110
+ renderGeometricTemplate(ctx, template) {
2111
+ const { x: cx, y: cy } = template.position;
2112
+ const r = template.radius;
2113
+ ctx.save();
2114
+ ctx.globalAlpha = template.opacity;
2115
+ ctx.fillStyle = template.fillColor;
2116
+ ctx.strokeStyle = template.strokeColor;
2117
+ ctx.lineWidth = template.strokeWidth;
2118
+ switch (template.templateShape) {
2119
+ case "circle":
2120
+ ctx.beginPath();
2121
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
2122
+ ctx.fill();
2123
+ ctx.stroke();
2124
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
2125
+ this.renderRadiusMarker(ctx, cx, cy, r, template.radiusFeet);
2126
+ }
2127
+ break;
2128
+ case "square":
2129
+ ctx.fillRect(cx - r / 2, cy - r / 2, r, r);
2130
+ ctx.strokeRect(cx - r / 2, cy - r / 2, r, r);
2131
+ break;
2132
+ case "cone": {
2133
+ const halfAngle = Math.atan(0.5);
2134
+ ctx.beginPath();
2135
+ ctx.moveTo(cx, cy);
2136
+ ctx.arc(cx, cy, r, template.angle - halfAngle, template.angle + halfAngle);
2137
+ ctx.closePath();
2138
+ ctx.fill();
2139
+ ctx.stroke();
2140
+ break;
2141
+ }
2142
+ case "line": {
2143
+ const halfW = r / 12;
2144
+ const cos = Math.cos(template.angle);
2145
+ const sin = Math.sin(template.angle);
2146
+ const perpX = -sin * halfW;
2147
+ const perpY = cos * halfW;
2148
+ ctx.beginPath();
2149
+ ctx.moveTo(cx + perpX, cy + perpY);
2150
+ ctx.lineTo(cx + r * cos + perpX, cy + r * sin + perpY);
2151
+ ctx.lineTo(cx + r * cos - perpX, cy + r * sin - perpY);
2152
+ ctx.lineTo(cx - perpX, cy - perpY);
2153
+ ctx.closePath();
2154
+ ctx.fill();
2155
+ ctx.stroke();
2156
+ break;
2157
+ }
2158
+ }
2159
+ ctx.restore();
2160
+ }
2161
+ renderHexTemplate(ctx, template, cellSize, orientation) {
2162
+ const snapUnit = Math.sqrt(3) * cellSize;
2163
+ const radiusCells = template.radius / snapUnit;
2164
+ const center = template.position;
2165
+ let cells;
2166
+ switch (template.templateShape) {
2167
+ case "circle":
2168
+ cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
2169
+ break;
2170
+ case "cone":
2171
+ cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
2172
+ break;
2173
+ case "line":
2174
+ cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
2175
+ break;
2176
+ case "square":
2177
+ cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
2178
+ break;
2179
+ }
2180
+ ctx.save();
2181
+ ctx.globalAlpha = template.opacity;
2182
+ ctx.beginPath();
2183
+ for (const cell of cells) {
2184
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2185
+ }
2186
+ ctx.fillStyle = template.fillColor;
2187
+ ctx.fill();
2188
+ ctx.beginPath();
2189
+ for (const cell of cells) {
2190
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2191
+ }
2192
+ ctx.strokeStyle = template.strokeColor;
2193
+ ctx.lineWidth = template.strokeWidth;
2194
+ ctx.stroke();
2195
+ {
2196
+ ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
2197
+ ctx.beginPath();
2198
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
2199
+ ctx.fillStyle = template.strokeColor;
2200
+ ctx.fill();
2201
+ ctx.strokeStyle = template.strokeColor;
2202
+ ctx.lineWidth = template.strokeWidth;
2203
+ ctx.stroke();
2204
+ }
2205
+ if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
2206
+ const r = template.radius;
2207
+ this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
2208
+ }
2209
+ ctx.restore();
2210
+ }
2211
+ renderRadiusMarker(ctx, cx, cy, r, feet) {
2212
+ const markerColor = ctx.strokeStyle;
2213
+ ctx.save();
2214
+ ctx.globalAlpha = 1;
2215
+ ctx.beginPath();
2216
+ ctx.setLineDash([4, 4]);
2217
+ ctx.strokeStyle = markerColor;
2218
+ ctx.lineWidth = 1.5;
2219
+ ctx.moveTo(cx, cy);
2220
+ ctx.lineTo(cx + r, cy);
2221
+ ctx.stroke();
2222
+ ctx.setLineDash([]);
2223
+ const label = `${Math.round(feet)} ft`;
2224
+ const fontSize = Math.max(10, Math.min(14, r * 0.15));
2225
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
2226
+ ctx.textAlign = "center";
2227
+ ctx.textBaseline = "bottom";
2228
+ const textX = cx + r / 2;
2229
+ const textY = cy - 4;
2230
+ const metrics = ctx.measureText(label);
2231
+ const padX = 4;
2232
+ const padY = 2;
2233
+ const textW = metrics.width + padX * 2;
2234
+ const textH = fontSize + padY * 2;
2235
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
2236
+ ctx.beginPath();
2237
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
2238
+ ctx.fill();
2239
+ ctx.fillStyle = markerColor;
2240
+ ctx.fillText(label, textX, textY - padY);
2241
+ ctx.restore();
2242
+ }
1821
2243
  renderImage(ctx, image) {
1822
2244
  const img = this.getImage(image.src);
1823
2245
  if (!img) return;
@@ -2325,6 +2747,25 @@ function createText(input) {
2325
2747
  textAlign: input.textAlign ?? "left"
2326
2748
  };
2327
2749
  }
2750
+ function createTemplate(input) {
2751
+ return {
2752
+ id: createId("template"),
2753
+ type: "template",
2754
+ position: input.position,
2755
+ zIndex: input.zIndex ?? 0,
2756
+ locked: input.locked ?? false,
2757
+ layerId: input.layerId ?? "",
2758
+ templateShape: input.templateShape,
2759
+ radius: input.radius,
2760
+ angle: input.angle ?? 0,
2761
+ fillColor: input.fillColor ?? "rgba(255, 87, 34, 0.2)",
2762
+ strokeColor: input.strokeColor ?? "#FF5722",
2763
+ strokeWidth: input.strokeWidth ?? 2,
2764
+ opacity: input.opacity ?? 0.6,
2765
+ feetPerCell: input.feetPerCell,
2766
+ radiusFeet: input.radiusFeet
2767
+ };
2768
+ }
2328
2769
 
2329
2770
  // src/canvas/export-image.ts
2330
2771
  function getStrokeBounds(el) {
@@ -2361,6 +2802,11 @@ function getElementRect(el) {
2361
2802
  }
2362
2803
  case "grid":
2363
2804
  return null;
2805
+ case "template": {
2806
+ const bounds = getElementBounds(el);
2807
+ if (!bounds) return null;
2808
+ return bounds;
2809
+ }
2364
2810
  case "note":
2365
2811
  case "image":
2366
2812
  case "html":
@@ -3105,7 +3551,15 @@ var RenderLoop = class {
3105
3551
  ctx.save();
3106
3552
  ctx.scale(dpr, dpr);
3107
3553
  this.renderer.setCanvasSize(cssWidth, cssHeight);
3108
- this.background.render(ctx, this.camera);
3554
+ const hasGridElement = this.store.getElementsByType("grid").length > 0;
3555
+ if (hasGridElement) {
3556
+ ctx.save();
3557
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3558
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
3559
+ ctx.restore();
3560
+ } else {
3561
+ this.background.render(ctx, this.camera);
3562
+ }
3109
3563
  ctx.save();
3110
3564
  ctx.translate(this.camera.position.x, this.camera.position.y);
3111
3565
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -3302,7 +3756,10 @@ var Viewport = class {
3302
3756
  this.renderer = new ElementRenderer();
3303
3757
  this.renderer.setStore(this.store);
3304
3758
  this.renderer.setCamera(this.camera);
3305
- this.renderer.setOnImageLoad(() => this.requestRender());
3759
+ this.renderer.setOnImageLoad(() => {
3760
+ this.renderLoop.markAllLayersDirty();
3761
+ this.requestRender();
3762
+ });
3306
3763
  this.noteEditor = new NoteEditor();
3307
3764
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
3308
3765
  this.history = new HistoryStack();
@@ -3363,16 +3820,19 @@ var Viewport = class {
3363
3820
  });
3364
3821
  this.unsubStore = [
3365
3822
  this.store.on("add", (el) => {
3823
+ if (el.type === "grid") this.syncGridContext();
3366
3824
  this.renderLoop.markLayerDirty(el.layerId);
3367
3825
  this.requestRender();
3368
3826
  }),
3369
3827
  this.store.on("remove", (el) => {
3828
+ if (el.type === "grid") this.syncGridContext();
3370
3829
  this.unbindArrowsFrom(el);
3371
3830
  this.domNodeManager.removeDomNode(el.id);
3372
3831
  this.renderLoop.markLayerDirty(el.layerId);
3373
3832
  this.requestRender();
3374
3833
  }),
3375
3834
  this.store.on("update", ({ previous, current }) => {
3835
+ if (current.type === "grid") this.syncGridContext();
3376
3836
  this.renderLoop.markLayerDirty(current.layerId);
3377
3837
  if (previous.layerId !== current.layerId) {
3378
3838
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -3382,6 +3842,7 @@ var Viewport = class {
3382
3842
  this.store.on("clear", () => {
3383
3843
  this.domNodeManager.clearDomNodes();
3384
3844
  this.renderLoop.markAllLayersDirty();
3845
+ this.syncGridContext();
3385
3846
  this.requestRender();
3386
3847
  })
3387
3848
  ];
@@ -3395,6 +3856,7 @@ var Viewport = class {
3395
3856
  this.observeResize();
3396
3857
  this.syncCanvasSize();
3397
3858
  this.renderLoop.start();
3859
+ this.syncGridContext();
3398
3860
  }
3399
3861
  camera;
3400
3862
  store;
@@ -3711,6 +4173,18 @@ var Viewport = class {
3711
4173
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
3712
4174
  this.requestRender();
3713
4175
  }
4176
+ syncGridContext() {
4177
+ const grid = this.store.getElementsByType("grid")[0];
4178
+ if (grid) {
4179
+ this.toolContext.gridSize = grid.cellSize;
4180
+ this.toolContext.gridType = grid.gridType;
4181
+ this.toolContext.hexOrientation = grid.hexOrientation;
4182
+ } else {
4183
+ this.toolContext.gridSize = this._gridSize;
4184
+ this.toolContext.gridType = void 0;
4185
+ this.toolContext.hexOrientation = void 0;
4186
+ }
4187
+ }
3714
4188
  observeResize() {
3715
4189
  if (typeof ResizeObserver === "undefined") return;
3716
4190
  this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
@@ -4085,7 +4559,7 @@ var SelectTool = class {
4085
4559
  ctx.setCursor?.("default");
4086
4560
  }
4087
4561
  snap(point, ctx) {
4088
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
4562
+ return smartSnap(point, ctx);
4089
4563
  }
4090
4564
  onPointerDown(state, ctx) {
4091
4565
  this.ctx = ctx;
@@ -4102,6 +4576,12 @@ var SelectTool = class {
4102
4576
  ctx.requestRender();
4103
4577
  return;
4104
4578
  }
4579
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4580
+ if (templateResizeHit) {
4581
+ this.mode = { type: "resizing-template", elementId: templateResizeHit };
4582
+ ctx.requestRender();
4583
+ return;
4584
+ }
4105
4585
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4106
4586
  if (resizeHit) {
4107
4587
  const el = ctx.store.getById(resizeHit.elementId);
@@ -4136,6 +4616,11 @@ var SelectTool = class {
4136
4616
  applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
4137
4617
  return;
4138
4618
  }
4619
+ if (this.mode.type === "resizing-template") {
4620
+ ctx.setCursor?.("nwse-resize");
4621
+ this.handleTemplateResize(world, ctx);
4622
+ return;
4623
+ }
4139
4624
  if (this.mode.type === "resizing") {
4140
4625
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4141
4626
  this.handleResize(world, ctx);
@@ -4159,6 +4644,16 @@ var SelectTool = class {
4159
4644
  from: { x: el.from.x + dx, y: el.from.y + dy },
4160
4645
  to: { x: el.to.x + dx, y: el.to.y + dy }
4161
4646
  });
4647
+ } else if (ctx.gridType && "size" in el) {
4648
+ const centerX = el.position.x + el.size.w / 2 + dx;
4649
+ const centerY = el.position.y + el.size.h / 2 + dy;
4650
+ const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
4651
+ ctx.store.update(id, {
4652
+ position: {
4653
+ x: snappedCenter.x - el.size.w / 2,
4654
+ y: snappedCenter.y - el.size.h / 2
4655
+ }
4656
+ });
4162
4657
  } else {
4163
4658
  ctx.store.update(id, {
4164
4659
  position: { x: el.position.x + dx, y: el.position.y + dy }
@@ -4233,6 +4728,11 @@ var SelectTool = class {
4233
4728
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
4234
4729
  return;
4235
4730
  }
4731
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4732
+ if (templateResizeHit) {
4733
+ ctx.setCursor?.("nwse-resize");
4734
+ return;
4735
+ }
4236
4736
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4237
4737
  if (resizeHit) {
4238
4738
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -4369,6 +4869,24 @@ var SelectTool = class {
4369
4869
  );
4370
4870
  }
4371
4871
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4872
+ } else if (el.type === "template") {
4873
+ canvasCtx.setLineDash([]);
4874
+ canvasCtx.fillStyle = "#ffffff";
4875
+ const hx = bounds.x + bounds.w;
4876
+ const hy = bounds.y + bounds.h;
4877
+ canvasCtx.fillRect(
4878
+ hx - handleWorldSize / 2,
4879
+ hy - handleWorldSize / 2,
4880
+ handleWorldSize,
4881
+ handleWorldSize
4882
+ );
4883
+ canvasCtx.strokeRect(
4884
+ hx - handleWorldSize / 2,
4885
+ hy - handleWorldSize / 2,
4886
+ handleWorldSize,
4887
+ handleWorldSize
4888
+ );
4889
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4372
4890
  }
4373
4891
  }
4374
4892
  canvasCtx.restore();
@@ -4393,6 +4911,43 @@ var SelectTool = class {
4393
4911
  }
4394
4912
  canvasCtx.restore();
4395
4913
  }
4914
+ hitTestTemplateResizeHandle(world, ctx) {
4915
+ if (this._selectedIds.length === 0) return null;
4916
+ const zoom = ctx.camera.zoom;
4917
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
4918
+ for (const id of this._selectedIds) {
4919
+ const el = ctx.store.getById(id);
4920
+ if (!el || el.type !== "template") continue;
4921
+ const bounds = getElementBounds(el);
4922
+ if (!bounds) continue;
4923
+ const hx = bounds.x + bounds.w;
4924
+ const hy = bounds.y + bounds.h;
4925
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
4926
+ return id;
4927
+ }
4928
+ }
4929
+ return null;
4930
+ }
4931
+ handleTemplateResize(world, ctx) {
4932
+ if (this.mode.type !== "resizing-template") return;
4933
+ const el = ctx.store.getById(this.mode.elementId);
4934
+ if (!el || el.type !== "template" || el.locked) return;
4935
+ const dx = world.x - el.position.x;
4936
+ const dy = world.y - el.position.y;
4937
+ let newRadius = Math.sqrt(dx * dx + dy * dy);
4938
+ if (ctx.snapToGrid && ctx.gridSize && ctx.gridSize > 0) {
4939
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4940
+ newRadius = Math.max(snapUnit, Math.round(newRadius / snapUnit) * snapUnit);
4941
+ }
4942
+ newRadius = Math.max(MIN_ELEMENT_SIZE, newRadius);
4943
+ const updates = { radius: newRadius };
4944
+ if (el.feetPerCell != null && ctx.gridSize && ctx.gridSize > 0) {
4945
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4946
+ updates.radiusFeet = newRadius / snapUnit * el.feetPerCell;
4947
+ }
4948
+ ctx.store.update(this.mode.elementId, updates);
4949
+ ctx.requestRender();
4950
+ }
4396
4951
  getMarqueeRect() {
4397
4952
  if (this.mode.type !== "marquee") return null;
4398
4953
  const { start } = this.mode;
@@ -4449,6 +5004,11 @@ var SelectTool = class {
4449
5004
  if (el.type === "arrow") {
4450
5005
  return isNearBezier(point, el.from, el.to, el.bend, 10);
4451
5006
  }
5007
+ if (el.type === "template") {
5008
+ const bounds = getElementBounds(el);
5009
+ if (!bounds) return false;
5010
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
5011
+ }
4452
5012
  return false;
4453
5013
  }
4454
5014
  };
@@ -4506,7 +5066,7 @@ var ArrowTool = class {
4506
5066
  this.fromBinding = { elementId: target.id };
4507
5067
  this.fromTarget = target;
4508
5068
  } else {
4509
- this.start = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5069
+ this.start = smartSnap(world, ctx);
4510
5070
  this.fromBinding = void 0;
4511
5071
  this.fromTarget = null;
4512
5072
  }
@@ -4524,7 +5084,7 @@ var ArrowTool = class {
4524
5084
  this.end = getElementCenter(target);
4525
5085
  this.toTarget = target;
4526
5086
  } else {
4527
- this.end = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
5087
+ this.end = smartSnap(world, ctx);
4528
5088
  this.toTarget = null;
4529
5089
  }
4530
5090
  ctx.requestRender();
@@ -4637,9 +5197,7 @@ var NoteTool = class {
4637
5197
  }
4638
5198
  onPointerUp(state, ctx) {
4639
5199
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4640
- if (ctx.snapToGrid && ctx.gridSize) {
4641
- world = snapPoint(world, ctx.gridSize);
4642
- }
5200
+ world = smartSnap(world, ctx);
4643
5201
  const note = createNote({
4644
5202
  position: world,
4645
5203
  size: { ...this.size },
@@ -4694,9 +5252,7 @@ var TextTool = class {
4694
5252
  }
4695
5253
  onPointerUp(state, ctx) {
4696
5254
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4697
- if (ctx.snapToGrid && ctx.gridSize) {
4698
- world = snapPoint(world, ctx.gridSize);
4699
- }
5255
+ world = smartSnap(world, ctx);
4700
5256
  const textEl = createText({
4701
5257
  position: world,
4702
5258
  fontSize: this.fontSize,
@@ -4729,8 +5285,12 @@ var ImageTool = class {
4729
5285
  onPointerUp(state, ctx) {
4730
5286
  if (!this.src) return;
4731
5287
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5288
+ const snapped = smartSnap(world, ctx);
4732
5289
  const image = createImage({
4733
- position: world,
5290
+ position: {
5291
+ x: snapped.x - this.size.w / 2,
5292
+ y: snapped.y - this.size.h / 2
5293
+ },
4734
5294
  size: { ...this.size },
4735
5295
  src: this.src
4736
5296
  });
@@ -4867,7 +5427,7 @@ var ShapeTool = class {
4867
5427
  for (const listener of this.optionListeners) listener();
4868
5428
  }
4869
5429
  snap(point, ctx) {
4870
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
5430
+ return smartSnap(point, ctx);
4871
5431
  }
4872
5432
  onKeyDown = (e) => {
4873
5433
  if (e.key === "Shift") this.shiftHeld = true;
@@ -4877,6 +5437,398 @@ var ShapeTool = class {
4877
5437
  };
4878
5438
  };
4879
5439
 
5440
+ // src/tools/measure-tool.ts
5441
+ var MeasureTool = class {
5442
+ name = "measure";
5443
+ start = null;
5444
+ end = null;
5445
+ gridSize = 1;
5446
+ gridType;
5447
+ hexOrientation;
5448
+ feetPerCell;
5449
+ optionListeners = /* @__PURE__ */ new Set();
5450
+ constructor(options = {}) {
5451
+ this.feetPerCell = options.feetPerCell ?? 5;
5452
+ }
5453
+ getOptions() {
5454
+ return { feetPerCell: this.feetPerCell };
5455
+ }
5456
+ setOptions(options) {
5457
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5458
+ this.notifyOptionsChange();
5459
+ }
5460
+ onOptionsChange(listener) {
5461
+ this.optionListeners.add(listener);
5462
+ return () => this.optionListeners.delete(listener);
5463
+ }
5464
+ onPointerDown(state, ctx) {
5465
+ this.gridSize = ctx.gridSize ?? 1;
5466
+ this.gridType = ctx.gridType;
5467
+ this.hexOrientation = ctx.hexOrientation;
5468
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5469
+ this.start = this.snapToGrid(world, ctx);
5470
+ this.end = { ...this.start };
5471
+ }
5472
+ onPointerMove(state, ctx) {
5473
+ if (!this.start) return;
5474
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5475
+ this.end = this.snapToGrid(world, ctx);
5476
+ ctx.requestRender();
5477
+ }
5478
+ onPointerUp(_state, ctx) {
5479
+ if (!this.start) return;
5480
+ this.start = null;
5481
+ this.end = null;
5482
+ ctx.requestRender();
5483
+ }
5484
+ onDeactivate(_ctx) {
5485
+ this.start = null;
5486
+ this.end = null;
5487
+ }
5488
+ getMeasurement() {
5489
+ if (!this.start || !this.end) return null;
5490
+ const dx = this.end.x - this.start.x;
5491
+ const dy = this.end.y - this.start.y;
5492
+ const worldDistance = Math.sqrt(dx * dx + dy * dy);
5493
+ let cells;
5494
+ if (this.gridType === "hex" && this.hexOrientation) {
5495
+ cells = getHexDistance(this.start, this.end, this.gridSize, this.hexOrientation);
5496
+ } else {
5497
+ const snapUnit = this.gridSize;
5498
+ cells = worldDistance / snapUnit;
5499
+ }
5500
+ const feet = cells * this.feetPerCell;
5501
+ return {
5502
+ start: { ...this.start },
5503
+ end: { ...this.end },
5504
+ worldDistance,
5505
+ cells,
5506
+ feet
5507
+ };
5508
+ }
5509
+ renderOverlay(ctx) {
5510
+ const m = this.getMeasurement();
5511
+ if (!m) return;
5512
+ ctx.save();
5513
+ ctx.strokeStyle = "#FF5722";
5514
+ ctx.setLineDash([8, 4]);
5515
+ ctx.lineWidth = 2;
5516
+ ctx.beginPath();
5517
+ ctx.moveTo(m.start.x, m.start.y);
5518
+ ctx.lineTo(m.end.x, m.end.y);
5519
+ ctx.stroke();
5520
+ ctx.setLineDash([]);
5521
+ ctx.fillStyle = "#FF5722";
5522
+ const dotRadius = 4;
5523
+ ctx.beginPath();
5524
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
5525
+ ctx.fill();
5526
+ ctx.beginPath();
5527
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
5528
+ ctx.fill();
5529
+ const label = `${Math.round(m.feet)} ft`;
5530
+ const midX = (m.start.x + m.end.x) / 2;
5531
+ const midY = (m.start.y + m.end.y) / 2;
5532
+ ctx.font = "14px sans-serif";
5533
+ const metrics = ctx.measureText(label);
5534
+ const padX = 6;
5535
+ const padY = 4;
5536
+ const textH = 14;
5537
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
5538
+ ctx.beginPath();
5539
+ ctx.roundRect(
5540
+ midX - metrics.width / 2 - padX,
5541
+ midY - textH / 2 - padY,
5542
+ metrics.width + padX * 2,
5543
+ textH + padY * 2,
5544
+ 4
5545
+ );
5546
+ ctx.fill();
5547
+ ctx.fillStyle = "#FFFFFF";
5548
+ ctx.textAlign = "center";
5549
+ ctx.textBaseline = "middle";
5550
+ ctx.fillText(label, midX, midY);
5551
+ ctx.restore();
5552
+ }
5553
+ snapToGrid(point, ctx) {
5554
+ if (!ctx.gridSize) return point;
5555
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5556
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5557
+ }
5558
+ if (ctx.gridType === "square") {
5559
+ return snapPoint(point, ctx.gridSize);
5560
+ }
5561
+ if (ctx.snapToGrid) {
5562
+ return snapPoint(point, ctx.gridSize);
5563
+ }
5564
+ return point;
5565
+ }
5566
+ notifyOptionsChange() {
5567
+ for (const listener of this.optionListeners) listener();
5568
+ }
5569
+ };
5570
+
5571
+ // src/tools/template-tool.ts
5572
+ var TemplateTool = class {
5573
+ name = "template";
5574
+ drawing = false;
5575
+ origin = { x: 0, y: 0 };
5576
+ current = { x: 0, y: 0 };
5577
+ gridSize = 1;
5578
+ gridType;
5579
+ hexOrientation;
5580
+ snapEnabled = false;
5581
+ templateShape;
5582
+ fillColor;
5583
+ strokeColor;
5584
+ strokeWidth;
5585
+ opacity;
5586
+ feetPerCell;
5587
+ optionListeners = /* @__PURE__ */ new Set();
5588
+ constructor(options = {}) {
5589
+ this.templateShape = options.templateShape ?? "circle";
5590
+ this.fillColor = options.fillColor ?? "rgba(255, 87, 34, 0.2)";
5591
+ this.strokeColor = options.strokeColor ?? "#FF5722";
5592
+ this.strokeWidth = options.strokeWidth ?? 2;
5593
+ this.opacity = options.opacity ?? 0.6;
5594
+ this.feetPerCell = options.feetPerCell ?? 5;
5595
+ }
5596
+ getOptions() {
5597
+ return {
5598
+ templateShape: this.templateShape,
5599
+ fillColor: this.fillColor,
5600
+ strokeColor: this.strokeColor,
5601
+ strokeWidth: this.strokeWidth,
5602
+ opacity: this.opacity,
5603
+ feetPerCell: this.feetPerCell
5604
+ };
5605
+ }
5606
+ setOptions(options) {
5607
+ if (options.templateShape !== void 0) this.templateShape = options.templateShape;
5608
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
5609
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
5610
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
5611
+ if (options.opacity !== void 0) this.opacity = options.opacity;
5612
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5613
+ this.notifyOptionsChange();
5614
+ }
5615
+ onOptionsChange(listener) {
5616
+ this.optionListeners.add(listener);
5617
+ return () => this.optionListeners.delete(listener);
5618
+ }
5619
+ onPointerDown(state, ctx) {
5620
+ this.drawing = true;
5621
+ this.gridSize = ctx.gridSize ?? 1;
5622
+ this.gridType = ctx.gridType;
5623
+ this.hexOrientation = ctx.hexOrientation;
5624
+ this.snapEnabled = !!ctx.gridType || (ctx.snapToGrid ?? false);
5625
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5626
+ this.origin = this.snapToGrid(world, ctx);
5627
+ this.current = { ...this.origin };
5628
+ }
5629
+ onPointerMove(state, ctx) {
5630
+ if (!this.drawing) return;
5631
+ this.current = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5632
+ ctx.requestRender();
5633
+ }
5634
+ onPointerUp(_state, ctx) {
5635
+ if (!this.drawing) return;
5636
+ this.drawing = false;
5637
+ const radius = this.computeRadius();
5638
+ if (radius <= 0) return;
5639
+ const angle = this.computeAngle();
5640
+ const gridSize = ctx.gridSize;
5641
+ const snapUnit = gridSize && gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * gridSize : gridSize : 0;
5642
+ const cells = snapUnit > 0 ? radius / snapUnit : 0;
5643
+ const radiusFeet = cells * this.feetPerCell;
5644
+ const element = createTemplate({
5645
+ position: { ...this.origin },
5646
+ templateShape: this.templateShape,
5647
+ radius,
5648
+ angle,
5649
+ fillColor: this.fillColor,
5650
+ strokeColor: this.strokeColor,
5651
+ strokeWidth: this.strokeWidth,
5652
+ opacity: this.opacity,
5653
+ feetPerCell: this.feetPerCell,
5654
+ radiusFeet: radiusFeet > 0 ? radiusFeet : void 0,
5655
+ layerId: ctx.activeLayerId ?? ""
5656
+ });
5657
+ ctx.store.add(element);
5658
+ ctx.requestRender();
5659
+ ctx.switchTool?.("select");
5660
+ }
5661
+ onDeactivate(_ctx) {
5662
+ this.drawing = false;
5663
+ this.origin = { x: 0, y: 0 };
5664
+ this.current = { x: 0, y: 0 };
5665
+ }
5666
+ renderOverlay(ctx) {
5667
+ if (!this.drawing) return;
5668
+ const radius = this.computeRadius();
5669
+ if (radius <= 0) return;
5670
+ if (this.gridType === "hex" && this.hexOrientation) {
5671
+ this.renderHexOverlay(ctx, radius);
5672
+ return;
5673
+ }
5674
+ this.renderGeometricOverlay(ctx, radius);
5675
+ }
5676
+ renderGeometricOverlay(ctx, radius) {
5677
+ const cx = this.origin.x;
5678
+ const cy = this.origin.y;
5679
+ const angle = this.computeAngle();
5680
+ ctx.save();
5681
+ ctx.globalAlpha = 0.4;
5682
+ ctx.fillStyle = this.fillColor;
5683
+ ctx.strokeStyle = this.strokeColor;
5684
+ ctx.lineWidth = this.strokeWidth;
5685
+ switch (this.templateShape) {
5686
+ case "circle":
5687
+ ctx.beginPath();
5688
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
5689
+ ctx.fill();
5690
+ ctx.stroke();
5691
+ break;
5692
+ case "square":
5693
+ ctx.fillRect(cx - radius / 2, cy - radius / 2, radius, radius);
5694
+ ctx.strokeRect(cx - radius / 2, cy - radius / 2, radius, radius);
5695
+ break;
5696
+ case "cone": {
5697
+ const halfAngle = Math.atan(0.5);
5698
+ ctx.beginPath();
5699
+ ctx.moveTo(cx, cy);
5700
+ ctx.arc(cx, cy, radius, angle - halfAngle, angle + halfAngle);
5701
+ ctx.closePath();
5702
+ ctx.fill();
5703
+ ctx.stroke();
5704
+ break;
5705
+ }
5706
+ case "line": {
5707
+ const halfW = radius / 12;
5708
+ const cos = Math.cos(angle);
5709
+ const sin = Math.sin(angle);
5710
+ const perpX = -sin * halfW;
5711
+ const perpY = cos * halfW;
5712
+ ctx.beginPath();
5713
+ ctx.moveTo(cx + perpX, cy + perpY);
5714
+ ctx.lineTo(cx + radius * cos + perpX, cy + radius * sin + perpY);
5715
+ ctx.lineTo(cx + radius * cos - perpX, cy + radius * sin - perpY);
5716
+ ctx.lineTo(cx - perpX, cy - perpY);
5717
+ ctx.closePath();
5718
+ ctx.fill();
5719
+ ctx.stroke();
5720
+ break;
5721
+ }
5722
+ }
5723
+ ctx.restore();
5724
+ }
5725
+ renderHexOverlay(ctx, radius) {
5726
+ const orientation = this.hexOrientation;
5727
+ if (!orientation) return;
5728
+ const cellSize = this.gridSize;
5729
+ const snapUnit = Math.sqrt(3) * cellSize;
5730
+ const radiusCells = radius / snapUnit;
5731
+ const angle = this.computeAngle();
5732
+ const center = this.origin;
5733
+ let hexCells;
5734
+ switch (this.templateShape) {
5735
+ case "circle":
5736
+ hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
5737
+ break;
5738
+ case "cone":
5739
+ hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
5740
+ break;
5741
+ case "line":
5742
+ hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
5743
+ break;
5744
+ case "square":
5745
+ hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
5746
+ break;
5747
+ }
5748
+ ctx.save();
5749
+ ctx.globalAlpha = 0.4;
5750
+ ctx.beginPath();
5751
+ for (const cell of hexCells) {
5752
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5753
+ }
5754
+ ctx.fillStyle = this.fillColor;
5755
+ ctx.fill();
5756
+ ctx.beginPath();
5757
+ for (const cell of hexCells) {
5758
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5759
+ }
5760
+ ctx.strokeStyle = this.strokeColor;
5761
+ ctx.lineWidth = this.strokeWidth;
5762
+ ctx.stroke();
5763
+ if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
5764
+ ctx.globalAlpha = 0.5;
5765
+ ctx.beginPath();
5766
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
5767
+ ctx.fillStyle = this.strokeColor;
5768
+ ctx.fill();
5769
+ ctx.strokeStyle = this.strokeColor;
5770
+ ctx.lineWidth = this.strokeWidth;
5771
+ ctx.stroke();
5772
+ }
5773
+ if (this.templateShape === "circle") {
5774
+ const feet = radiusCells * this.feetPerCell;
5775
+ if (feet > 0) {
5776
+ ctx.globalAlpha = 1;
5777
+ const label = `${Math.round(feet)} ft`;
5778
+ const fontSize = Math.max(10, Math.min(14, radius * 0.15));
5779
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
5780
+ ctx.textAlign = "center";
5781
+ ctx.textBaseline = "bottom";
5782
+ const textX = center.x;
5783
+ const textY = center.y - 4;
5784
+ const metrics = ctx.measureText(label);
5785
+ const padX = 4;
5786
+ const padY = 2;
5787
+ const textW = metrics.width + padX * 2;
5788
+ const textH = fontSize + padY * 2;
5789
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
5790
+ ctx.beginPath();
5791
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
5792
+ ctx.fill();
5793
+ ctx.fillStyle = this.strokeColor;
5794
+ ctx.fillText(label, textX, textY - padY);
5795
+ }
5796
+ }
5797
+ ctx.restore();
5798
+ }
5799
+ computeRadius() {
5800
+ const dx = this.current.x - this.origin.x;
5801
+ const dy = this.current.y - this.origin.y;
5802
+ const raw = Math.sqrt(dx * dx + dy * dy);
5803
+ if (this.snapEnabled && this.gridSize > 0) {
5804
+ const snapUnit = this.gridType === "hex" ? Math.sqrt(3) * this.gridSize : this.gridSize;
5805
+ return Math.max(snapUnit, Math.round(raw / snapUnit) * snapUnit);
5806
+ }
5807
+ return raw;
5808
+ }
5809
+ computeAngle() {
5810
+ const dx = this.current.x - this.origin.x;
5811
+ const dy = this.current.y - this.origin.y;
5812
+ return Math.atan2(dy, dx);
5813
+ }
5814
+ snapToGrid(point, ctx) {
5815
+ if (!ctx.gridSize) return point;
5816
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5817
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5818
+ }
5819
+ if (ctx.gridType === "square") {
5820
+ return snapPoint(point, ctx.gridSize);
5821
+ }
5822
+ if (ctx.snapToGrid) {
5823
+ return snapPoint(point, ctx.gridSize);
5824
+ }
5825
+ return point;
5826
+ }
5827
+ notifyOptionsChange() {
5828
+ for (const listener of this.optionListeners) listener();
5829
+ }
5830
+ };
5831
+
4880
5832
  // src/history/layer-commands.ts
4881
5833
  var CreateLayerCommand = class {
4882
5834
  constructor(manager, layer) {
@@ -4918,7 +5870,7 @@ var UpdateLayerCommand = class {
4918
5870
  };
4919
5871
 
4920
5872
  // src/index.ts
4921
- var VERSION = "0.8.10";
5873
+ var VERSION = "0.9.0";
4922
5874
  // Annotate the CommonJS export names for ESM import in node:
4923
5875
  0 && (module.exports = {
4924
5876
  AddElementCommand,
@@ -4938,6 +5890,7 @@ var VERSION = "0.8.10";
4938
5890
  ImageTool,
4939
5891
  InputHandler,
4940
5892
  LayerManager,
5893
+ MeasureTool,
4941
5894
  NoteEditor,
4942
5895
  NoteTool,
4943
5896
  PencilTool,
@@ -4946,6 +5899,7 @@ var VERSION = "0.8.10";
4946
5899
  RemoveLayerCommand,
4947
5900
  SelectTool,
4948
5901
  ShapeTool,
5902
+ TemplateTool,
4949
5903
  TextTool,
4950
5904
  ToolManager,
4951
5905
  UpdateElementCommand,
@@ -4962,7 +5916,9 @@ var VERSION = "0.8.10";
4962
5916
  createNote,
4963
5917
  createShape,
4964
5918
  createStroke,
5919
+ createTemplate,
4965
5920
  createText,
5921
+ drawHexPath,
4966
5922
  exportImage,
4967
5923
  exportState,
4968
5924
  findBindTarget,
@@ -4975,10 +5931,17 @@ var VERSION = "0.8.10";
4975
5931
  getEdgeIntersection,
4976
5932
  getElementBounds,
4977
5933
  getElementCenter,
5934
+ getHexCellsInCone,
5935
+ getHexCellsInLine,
5936
+ getHexCellsInRadius,
5937
+ getHexCellsInSquare,
5938
+ getHexDistance,
4978
5939
  isBindable,
4979
5940
  isNearBezier,
4980
5941
  parseState,
5942
+ smartSnap,
4981
5943
  snapPoint,
5944
+ snapToHexCenter,
4982
5945
  unbindArrow,
4983
5946
  updateBoundArrow
4984
5947
  });