@fieldnotes/core 0.8.11 → 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.js CHANGED
@@ -290,6 +290,30 @@ function snapPoint(point, gridSize) {
290
290
  y: Math.round(point.y / gridSize) * gridSize || 0
291
291
  };
292
292
  }
293
+ function snapToHexCenter(point, cellSize, orientation) {
294
+ if (orientation === "pointy") {
295
+ const hexW = Math.sqrt(3) * cellSize;
296
+ const rowH = 1.5 * cellSize;
297
+ const row = Math.round(point.y / rowH);
298
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
299
+ const col = Math.round((point.x - offsetX) / hexW);
300
+ return { x: col * hexW + offsetX || 0, y: row * rowH || 0 };
301
+ } else {
302
+ const hexH = Math.sqrt(3) * cellSize;
303
+ const colW = 1.5 * cellSize;
304
+ const col = Math.round(point.x / colW);
305
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
306
+ const row = Math.round((point.y - offsetY) / hexH);
307
+ return { x: col * colW || 0, y: row * hexH + offsetY || 0 };
308
+ }
309
+ }
310
+ function smartSnap(point, ctx) {
311
+ if (!ctx.snapToGrid || !ctx.gridSize) return point;
312
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
313
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
314
+ }
315
+ return snapPoint(point, ctx.gridSize);
316
+ }
293
317
 
294
318
  // src/core/auto-save.ts
295
319
  var DEFAULT_KEY = "fieldnotes-autosave";
@@ -942,6 +966,9 @@ function getElementBounds(element) {
942
966
  if (element.type === "arrow") {
943
967
  return getArrowBoundsAnalytical(element.from, element.to, element.bend);
944
968
  }
969
+ if (element.type === "template") {
970
+ return getTemplateBounds(element);
971
+ }
945
972
  return null;
946
973
  }
947
974
  function getArrowBoundsAnalytical(from, to, bend) {
@@ -982,6 +1009,62 @@ function getArrowBoundsAnalytical(from, to, bend) {
982
1009
  }
983
1010
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
984
1011
  }
1012
+ function getTemplateBounds(el) {
1013
+ const { x: cx, y: cy } = el.position;
1014
+ const r = el.radius;
1015
+ switch (el.templateShape) {
1016
+ case "circle":
1017
+ return { x: cx - r, y: cy - r, w: 2 * r, h: 2 * r };
1018
+ case "square":
1019
+ return { x: cx - r / 2, y: cy - r / 2, w: r, h: r };
1020
+ case "cone": {
1021
+ const halfAngle = Math.atan(0.5);
1022
+ const tipX = cx;
1023
+ const tipY = cy;
1024
+ const leftX = cx + r * Math.cos(el.angle - halfAngle);
1025
+ const leftY = cy + r * Math.sin(el.angle - halfAngle);
1026
+ const rightX = cx + r * Math.cos(el.angle + halfAngle);
1027
+ const rightY = cy + r * Math.sin(el.angle + halfAngle);
1028
+ const farX = cx + r * Math.cos(el.angle);
1029
+ const farY = cy + r * Math.sin(el.angle);
1030
+ const xs = [tipX, leftX, rightX, farX];
1031
+ const ys = [tipY, leftY, rightY, farY];
1032
+ let minX = Infinity;
1033
+ let minY = Infinity;
1034
+ let maxX = -Infinity;
1035
+ let maxY = -Infinity;
1036
+ for (let i = 0; i < xs.length; i++) {
1037
+ const px = xs[i];
1038
+ const py = ys[i];
1039
+ if (px !== void 0 && px < minX) minX = px;
1040
+ if (px !== void 0 && px > maxX) maxX = px;
1041
+ if (py !== void 0 && py < minY) minY = py;
1042
+ if (py !== void 0 && py > maxY) maxY = py;
1043
+ }
1044
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1045
+ }
1046
+ case "line": {
1047
+ const halfW = r / 12;
1048
+ const cos = Math.cos(el.angle);
1049
+ const sin = Math.sin(el.angle);
1050
+ const perpX = -sin * halfW;
1051
+ const perpY = cos * halfW;
1052
+ const x0 = cx + perpX;
1053
+ const y0 = cy + perpY;
1054
+ const x1 = cx + r * cos + perpX;
1055
+ const y1 = cy + r * sin + perpY;
1056
+ const x2 = cx + r * cos - perpX;
1057
+ const y2 = cy + r * sin - perpY;
1058
+ const x3 = cx - perpX;
1059
+ const y3 = cy - perpY;
1060
+ const minX = Math.min(x0, x1, x2, x3);
1061
+ const minY = Math.min(y0, y1, y2, y3);
1062
+ const maxX = Math.max(x0, x1, x2, x3);
1063
+ const maxY = Math.max(y0, y1, y2, y3);
1064
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1065
+ }
1066
+ }
1067
+ }
985
1068
  function boundsIntersect(a, b) {
986
1069
  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;
987
1070
  }
@@ -1502,6 +1585,190 @@ function renderHexGridTiled(ctx, bounds, cellSize, tile) {
1502
1585
  }
1503
1586
  }
1504
1587
 
1588
+ // src/elements/hex-fill.ts
1589
+ function offsetToCube(col, row, orientation) {
1590
+ if (orientation === "pointy") {
1591
+ return { q: col - (row - (row & 1)) / 2, r: row };
1592
+ }
1593
+ return { q: col, r: row - (col - (col & 1)) / 2 };
1594
+ }
1595
+ function cubeToOffset(q, r, orientation) {
1596
+ if (orientation === "pointy") {
1597
+ return { col: q + (r - (r & 1)) / 2, row: r };
1598
+ }
1599
+ return { col: q, row: r + (q - (q & 1)) / 2 };
1600
+ }
1601
+ function offsetToPixel(col, row, cellSize, orientation) {
1602
+ if (orientation === "pointy") {
1603
+ const hexW = Math.sqrt(3) * cellSize;
1604
+ const rowH = 1.5 * cellSize;
1605
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1606
+ return { x: col * hexW + offsetX, y: row * rowH };
1607
+ }
1608
+ const hexH = Math.sqrt(3) * cellSize;
1609
+ const colW = 1.5 * cellSize;
1610
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1611
+ return { x: col * colW, y: row * hexH + offsetY };
1612
+ }
1613
+ function pixelToOffset(x, y, cellSize, orientation) {
1614
+ if (orientation === "pointy") {
1615
+ const hexW = Math.sqrt(3) * cellSize;
1616
+ const rowH = 1.5 * cellSize;
1617
+ const row = Math.round(y / rowH);
1618
+ const offsetX = row % 2 !== 0 ? hexW / 2 : 0;
1619
+ return { col: Math.round((x - offsetX) / hexW), row };
1620
+ }
1621
+ const hexH = Math.sqrt(3) * cellSize;
1622
+ const colW = 1.5 * cellSize;
1623
+ const col = Math.round(x / colW);
1624
+ const offsetY = col % 2 !== 0 ? hexH / 2 : 0;
1625
+ return { col, row: Math.round((y - offsetY) / hexH) };
1626
+ }
1627
+ function enumerateHexRing(centerQ, centerR, n, orientation, cellSize) {
1628
+ const cells = [];
1629
+ for (let dq = -n; dq <= n; dq++) {
1630
+ const rMin = Math.max(-n, -dq - n);
1631
+ const rMax = Math.min(n, -dq + n);
1632
+ for (let dr = rMin; dr <= rMax; dr++) {
1633
+ const absQ = centerQ + dq;
1634
+ const absR = centerR + dr;
1635
+ const off = cubeToOffset(absQ, absR, orientation);
1636
+ cells.push(offsetToPixel(off.col, off.row, cellSize, orientation));
1637
+ }
1638
+ }
1639
+ return cells;
1640
+ }
1641
+ function getHexDistance(a, b, cellSize, orientation) {
1642
+ const offA = pixelToOffset(a.x, a.y, cellSize, orientation);
1643
+ const offB = pixelToOffset(b.x, b.y, cellSize, orientation);
1644
+ const cubeA = offsetToCube(offA.col, offA.row, orientation);
1645
+ const cubeB = offsetToCube(offB.col, offB.row, orientation);
1646
+ const dq = cubeA.q - cubeB.q;
1647
+ const dr = cubeA.r - cubeB.r;
1648
+ const ds = -dq - dr;
1649
+ return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds));
1650
+ }
1651
+ function getHexCellsInRadius(center, radiusCells, cellSize, orientation) {
1652
+ const n = Math.round(radiusCells);
1653
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1654
+ const cube = offsetToCube(off.col, off.row, orientation);
1655
+ if (n <= 0) {
1656
+ return [offsetToPixel(off.col, off.row, cellSize, orientation)];
1657
+ }
1658
+ return enumerateHexRing(cube.q, cube.r, n, orientation, cellSize);
1659
+ }
1660
+ function getHexCellsInCone(center, angle, radiusCells, cellSize, orientation) {
1661
+ const n = Math.round(radiusCells);
1662
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1663
+ const cube = offsetToCube(off.col, off.row, orientation);
1664
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1665
+ if (n <= 0) return [centerPixel];
1666
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1667
+ const step = Math.PI / 3;
1668
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1669
+ const halfAngle = Math.PI / 6 + 1e-6;
1670
+ const cells = [centerPixel];
1671
+ for (let dq = -n; dq <= n; dq++) {
1672
+ const rMin = Math.max(-n, -dq - n);
1673
+ const rMax = Math.min(n, -dq + n);
1674
+ for (let dr = rMin; dr <= rMax; dr++) {
1675
+ if (dq === 0 && dr === 0) continue;
1676
+ const absQ = cube.q + dq;
1677
+ const absR = cube.r + dr;
1678
+ const pixel = offsetToPixel(
1679
+ cubeToOffset(absQ, absR, orientation).col,
1680
+ cubeToOffset(absQ, absR, orientation).row,
1681
+ cellSize,
1682
+ orientation
1683
+ );
1684
+ const dx = pixel.x - centerPixel.x;
1685
+ const dy = pixel.y - centerPixel.y;
1686
+ let diff = Math.atan2(dy, dx) - snappedAngle;
1687
+ if (diff > Math.PI) diff -= 2 * Math.PI;
1688
+ if (diff < -Math.PI) diff += 2 * Math.PI;
1689
+ if (Math.abs(diff) <= halfAngle) {
1690
+ cells.push(pixel);
1691
+ }
1692
+ }
1693
+ }
1694
+ return cells;
1695
+ }
1696
+ function getHexCellsInLine(center, angle, radiusCells, cellSize, orientation) {
1697
+ const n = Math.round(radiusCells);
1698
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1699
+ const cube = offsetToCube(off.col, off.row, orientation);
1700
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1701
+ if (n <= 0) return [centerPixel];
1702
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1703
+ const step = Math.PI / 3;
1704
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
1705
+ const cos = Math.cos(snappedAngle);
1706
+ const sin = Math.sin(snappedAngle);
1707
+ const snapUnit = Math.sqrt(3) * cellSize;
1708
+ const lineLength = n * snapUnit;
1709
+ const halfWidth = snapUnit * 0.5 + 1e-6;
1710
+ const cells = [];
1711
+ for (let dq = -n; dq <= n; dq++) {
1712
+ const rMin = Math.max(-n, -dq - n);
1713
+ const rMax = Math.min(n, -dq + n);
1714
+ for (let dr = rMin; dr <= rMax; dr++) {
1715
+ const absQ = cube.q + dq;
1716
+ const absR = cube.r + dr;
1717
+ const pixel = offsetToPixel(
1718
+ cubeToOffset(absQ, absR, orientation).col,
1719
+ cubeToOffset(absQ, absR, orientation).row,
1720
+ cellSize,
1721
+ orientation
1722
+ );
1723
+ const dx = pixel.x - centerPixel.x;
1724
+ const dy = pixel.y - centerPixel.y;
1725
+ const along = dx * cos + dy * sin;
1726
+ const perp = Math.abs(-dx * sin + dy * cos);
1727
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
1728
+ cells.push(pixel);
1729
+ }
1730
+ }
1731
+ }
1732
+ return cells;
1733
+ }
1734
+ function getHexCellsInSquare(center, radiusCells, cellSize, orientation) {
1735
+ const n = Math.round(radiusCells);
1736
+ const off = pixelToOffset(center.x, center.y, cellSize, orientation);
1737
+ const cube = offsetToCube(off.col, off.row, orientation);
1738
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
1739
+ if (n <= 0) return [centerPixel];
1740
+ const snapUnit = Math.sqrt(3) * cellSize;
1741
+ const halfSide = n * snapUnit / 2;
1742
+ const cells = [];
1743
+ for (let dq = -n; dq <= n; dq++) {
1744
+ const rMin = Math.max(-n, -dq - n);
1745
+ const rMax = Math.min(n, -dq + n);
1746
+ for (let dr = rMin; dr <= rMax; dr++) {
1747
+ const absQ = cube.q + dq;
1748
+ const absR = cube.r + dr;
1749
+ const pixel = offsetToPixel(
1750
+ cubeToOffset(absQ, absR, orientation).col,
1751
+ cubeToOffset(absQ, absR, orientation).row,
1752
+ cellSize,
1753
+ orientation
1754
+ );
1755
+ if (Math.abs(pixel.x - centerPixel.x) <= halfSide && Math.abs(pixel.y - centerPixel.y) <= halfSide) {
1756
+ cells.push(pixel);
1757
+ }
1758
+ }
1759
+ }
1760
+ return cells;
1761
+ }
1762
+ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
1763
+ const angleOffset = orientation === "pointy" ? Math.PI / 6 : 0;
1764
+ ctx.moveTo(cx + cellSize * Math.cos(angleOffset), cy + cellSize * Math.sin(angleOffset));
1765
+ for (let i = 1; i < 6; i++) {
1766
+ const a = angleOffset + Math.PI / 3 * i;
1767
+ ctx.lineTo(cx + cellSize * Math.cos(a), cy + cellSize * Math.sin(a));
1768
+ }
1769
+ ctx.closePath();
1770
+ }
1771
+
1505
1772
  // src/elements/element-renderer.ts
1506
1773
  var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "html", "text"]);
1507
1774
  var ARROWHEAD_LENGTH = 12;
@@ -1546,6 +1813,9 @@ var ElementRenderer = class {
1546
1813
  case "grid":
1547
1814
  this.renderGrid(ctx, element);
1548
1815
  break;
1816
+ case "template":
1817
+ this.renderTemplate(ctx, element);
1818
+ break;
1549
1819
  }
1550
1820
  }
1551
1821
  renderStroke(ctx, stroke) {
@@ -1733,6 +2003,147 @@ var ElementRenderer = class {
1733
2003
  );
1734
2004
  }
1735
2005
  }
2006
+ renderTemplate(ctx, template) {
2007
+ const grid = this.store?.getElementsByType("grid")[0];
2008
+ if (grid && grid.gridType === "hex") {
2009
+ this.renderHexTemplate(ctx, template, grid.cellSize, grid.hexOrientation);
2010
+ return;
2011
+ }
2012
+ this.renderGeometricTemplate(ctx, template);
2013
+ }
2014
+ renderGeometricTemplate(ctx, template) {
2015
+ const { x: cx, y: cy } = template.position;
2016
+ const r = template.radius;
2017
+ ctx.save();
2018
+ ctx.globalAlpha = template.opacity;
2019
+ ctx.fillStyle = template.fillColor;
2020
+ ctx.strokeStyle = template.strokeColor;
2021
+ ctx.lineWidth = template.strokeWidth;
2022
+ switch (template.templateShape) {
2023
+ case "circle":
2024
+ ctx.beginPath();
2025
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
2026
+ ctx.fill();
2027
+ ctx.stroke();
2028
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
2029
+ this.renderRadiusMarker(ctx, cx, cy, r, template.radiusFeet);
2030
+ }
2031
+ break;
2032
+ case "square":
2033
+ ctx.fillRect(cx - r / 2, cy - r / 2, r, r);
2034
+ ctx.strokeRect(cx - r / 2, cy - r / 2, r, r);
2035
+ break;
2036
+ case "cone": {
2037
+ const halfAngle = Math.atan(0.5);
2038
+ ctx.beginPath();
2039
+ ctx.moveTo(cx, cy);
2040
+ ctx.arc(cx, cy, r, template.angle - halfAngle, template.angle + halfAngle);
2041
+ ctx.closePath();
2042
+ ctx.fill();
2043
+ ctx.stroke();
2044
+ break;
2045
+ }
2046
+ case "line": {
2047
+ const halfW = r / 12;
2048
+ const cos = Math.cos(template.angle);
2049
+ const sin = Math.sin(template.angle);
2050
+ const perpX = -sin * halfW;
2051
+ const perpY = cos * halfW;
2052
+ ctx.beginPath();
2053
+ ctx.moveTo(cx + perpX, cy + perpY);
2054
+ ctx.lineTo(cx + r * cos + perpX, cy + r * sin + perpY);
2055
+ ctx.lineTo(cx + r * cos - perpX, cy + r * sin - perpY);
2056
+ ctx.lineTo(cx - perpX, cy - perpY);
2057
+ ctx.closePath();
2058
+ ctx.fill();
2059
+ ctx.stroke();
2060
+ break;
2061
+ }
2062
+ }
2063
+ ctx.restore();
2064
+ }
2065
+ renderHexTemplate(ctx, template, cellSize, orientation) {
2066
+ const snapUnit = Math.sqrt(3) * cellSize;
2067
+ const radiusCells = template.radius / snapUnit;
2068
+ const center = template.position;
2069
+ let cells;
2070
+ switch (template.templateShape) {
2071
+ case "circle":
2072
+ cells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
2073
+ break;
2074
+ case "cone":
2075
+ cells = getHexCellsInCone(center, template.angle, radiusCells, cellSize, orientation);
2076
+ break;
2077
+ case "line":
2078
+ cells = getHexCellsInLine(center, template.angle, radiusCells, cellSize, orientation);
2079
+ break;
2080
+ case "square":
2081
+ cells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
2082
+ break;
2083
+ }
2084
+ ctx.save();
2085
+ ctx.globalAlpha = template.opacity;
2086
+ ctx.beginPath();
2087
+ for (const cell of cells) {
2088
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2089
+ }
2090
+ ctx.fillStyle = template.fillColor;
2091
+ ctx.fill();
2092
+ ctx.beginPath();
2093
+ for (const cell of cells) {
2094
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
2095
+ }
2096
+ ctx.strokeStyle = template.strokeColor;
2097
+ ctx.lineWidth = template.strokeWidth;
2098
+ ctx.stroke();
2099
+ {
2100
+ ctx.globalAlpha = Math.min(template.opacity + 0.1, 1);
2101
+ ctx.beginPath();
2102
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
2103
+ ctx.fillStyle = template.strokeColor;
2104
+ ctx.fill();
2105
+ ctx.strokeStyle = template.strokeColor;
2106
+ ctx.lineWidth = template.strokeWidth;
2107
+ ctx.stroke();
2108
+ }
2109
+ if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
2110
+ const r = template.radius;
2111
+ this.renderRadiusMarker(ctx, center.x, center.y, r, template.radiusFeet);
2112
+ }
2113
+ ctx.restore();
2114
+ }
2115
+ renderRadiusMarker(ctx, cx, cy, r, feet) {
2116
+ const markerColor = ctx.strokeStyle;
2117
+ ctx.save();
2118
+ ctx.globalAlpha = 1;
2119
+ ctx.beginPath();
2120
+ ctx.setLineDash([4, 4]);
2121
+ ctx.strokeStyle = markerColor;
2122
+ ctx.lineWidth = 1.5;
2123
+ ctx.moveTo(cx, cy);
2124
+ ctx.lineTo(cx + r, cy);
2125
+ ctx.stroke();
2126
+ ctx.setLineDash([]);
2127
+ const label = `${Math.round(feet)} ft`;
2128
+ const fontSize = Math.max(10, Math.min(14, r * 0.15));
2129
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
2130
+ ctx.textAlign = "center";
2131
+ ctx.textBaseline = "bottom";
2132
+ const textX = cx + r / 2;
2133
+ const textY = cy - 4;
2134
+ const metrics = ctx.measureText(label);
2135
+ const padX = 4;
2136
+ const padY = 2;
2137
+ const textW = metrics.width + padX * 2;
2138
+ const textH = fontSize + padY * 2;
2139
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
2140
+ ctx.beginPath();
2141
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
2142
+ ctx.fill();
2143
+ ctx.fillStyle = markerColor;
2144
+ ctx.fillText(label, textX, textY - padY);
2145
+ ctx.restore();
2146
+ }
1736
2147
  renderImage(ctx, image) {
1737
2148
  const img = this.getImage(image.src);
1738
2149
  if (!img) return;
@@ -2240,6 +2651,25 @@ function createText(input) {
2240
2651
  textAlign: input.textAlign ?? "left"
2241
2652
  };
2242
2653
  }
2654
+ function createTemplate(input) {
2655
+ return {
2656
+ id: createId("template"),
2657
+ type: "template",
2658
+ position: input.position,
2659
+ zIndex: input.zIndex ?? 0,
2660
+ locked: input.locked ?? false,
2661
+ layerId: input.layerId ?? "",
2662
+ templateShape: input.templateShape,
2663
+ radius: input.radius,
2664
+ angle: input.angle ?? 0,
2665
+ fillColor: input.fillColor ?? "rgba(255, 87, 34, 0.2)",
2666
+ strokeColor: input.strokeColor ?? "#FF5722",
2667
+ strokeWidth: input.strokeWidth ?? 2,
2668
+ opacity: input.opacity ?? 0.6,
2669
+ feetPerCell: input.feetPerCell,
2670
+ radiusFeet: input.radiusFeet
2671
+ };
2672
+ }
2243
2673
 
2244
2674
  // src/canvas/export-image.ts
2245
2675
  function getStrokeBounds(el) {
@@ -2276,6 +2706,11 @@ function getElementRect(el) {
2276
2706
  }
2277
2707
  case "grid":
2278
2708
  return null;
2709
+ case "template": {
2710
+ const bounds = getElementBounds(el);
2711
+ if (!bounds) return null;
2712
+ return bounds;
2713
+ }
2279
2714
  case "note":
2280
2715
  case "image":
2281
2716
  case "html":
@@ -3020,7 +3455,15 @@ var RenderLoop = class {
3020
3455
  ctx.save();
3021
3456
  ctx.scale(dpr, dpr);
3022
3457
  this.renderer.setCanvasSize(cssWidth, cssHeight);
3023
- this.background.render(ctx, this.camera);
3458
+ const hasGridElement = this.store.getElementsByType("grid").length > 0;
3459
+ if (hasGridElement) {
3460
+ ctx.save();
3461
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
3462
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
3463
+ ctx.restore();
3464
+ } else {
3465
+ this.background.render(ctx, this.camera);
3466
+ }
3024
3467
  ctx.save();
3025
3468
  ctx.translate(this.camera.position.x, this.camera.position.y);
3026
3469
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -3281,16 +3724,19 @@ var Viewport = class {
3281
3724
  });
3282
3725
  this.unsubStore = [
3283
3726
  this.store.on("add", (el) => {
3727
+ if (el.type === "grid") this.syncGridContext();
3284
3728
  this.renderLoop.markLayerDirty(el.layerId);
3285
3729
  this.requestRender();
3286
3730
  }),
3287
3731
  this.store.on("remove", (el) => {
3732
+ if (el.type === "grid") this.syncGridContext();
3288
3733
  this.unbindArrowsFrom(el);
3289
3734
  this.domNodeManager.removeDomNode(el.id);
3290
3735
  this.renderLoop.markLayerDirty(el.layerId);
3291
3736
  this.requestRender();
3292
3737
  }),
3293
3738
  this.store.on("update", ({ previous, current }) => {
3739
+ if (current.type === "grid") this.syncGridContext();
3294
3740
  this.renderLoop.markLayerDirty(current.layerId);
3295
3741
  if (previous.layerId !== current.layerId) {
3296
3742
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -3300,6 +3746,7 @@ var Viewport = class {
3300
3746
  this.store.on("clear", () => {
3301
3747
  this.domNodeManager.clearDomNodes();
3302
3748
  this.renderLoop.markAllLayersDirty();
3749
+ this.syncGridContext();
3303
3750
  this.requestRender();
3304
3751
  })
3305
3752
  ];
@@ -3313,6 +3760,7 @@ var Viewport = class {
3313
3760
  this.observeResize();
3314
3761
  this.syncCanvasSize();
3315
3762
  this.renderLoop.start();
3763
+ this.syncGridContext();
3316
3764
  }
3317
3765
  camera;
3318
3766
  store;
@@ -3629,6 +4077,18 @@ var Viewport = class {
3629
4077
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
3630
4078
  this.requestRender();
3631
4079
  }
4080
+ syncGridContext() {
4081
+ const grid = this.store.getElementsByType("grid")[0];
4082
+ if (grid) {
4083
+ this.toolContext.gridSize = grid.cellSize;
4084
+ this.toolContext.gridType = grid.gridType;
4085
+ this.toolContext.hexOrientation = grid.hexOrientation;
4086
+ } else {
4087
+ this.toolContext.gridSize = this._gridSize;
4088
+ this.toolContext.gridType = void 0;
4089
+ this.toolContext.hexOrientation = void 0;
4090
+ }
4091
+ }
3632
4092
  observeResize() {
3633
4093
  if (typeof ResizeObserver === "undefined") return;
3634
4094
  this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
@@ -4003,7 +4463,7 @@ var SelectTool = class {
4003
4463
  ctx.setCursor?.("default");
4004
4464
  }
4005
4465
  snap(point, ctx) {
4006
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
4466
+ return smartSnap(point, ctx);
4007
4467
  }
4008
4468
  onPointerDown(state, ctx) {
4009
4469
  this.ctx = ctx;
@@ -4020,6 +4480,12 @@ var SelectTool = class {
4020
4480
  ctx.requestRender();
4021
4481
  return;
4022
4482
  }
4483
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4484
+ if (templateResizeHit) {
4485
+ this.mode = { type: "resizing-template", elementId: templateResizeHit };
4486
+ ctx.requestRender();
4487
+ return;
4488
+ }
4023
4489
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4024
4490
  if (resizeHit) {
4025
4491
  const el = ctx.store.getById(resizeHit.elementId);
@@ -4054,6 +4520,11 @@ var SelectTool = class {
4054
4520
  applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
4055
4521
  return;
4056
4522
  }
4523
+ if (this.mode.type === "resizing-template") {
4524
+ ctx.setCursor?.("nwse-resize");
4525
+ this.handleTemplateResize(world, ctx);
4526
+ return;
4527
+ }
4057
4528
  if (this.mode.type === "resizing") {
4058
4529
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4059
4530
  this.handleResize(world, ctx);
@@ -4077,6 +4548,16 @@ var SelectTool = class {
4077
4548
  from: { x: el.from.x + dx, y: el.from.y + dy },
4078
4549
  to: { x: el.to.x + dx, y: el.to.y + dy }
4079
4550
  });
4551
+ } else if (ctx.gridType && "size" in el) {
4552
+ const centerX = el.position.x + el.size.w / 2 + dx;
4553
+ const centerY = el.position.y + el.size.h / 2 + dy;
4554
+ const snappedCenter = this.snap({ x: centerX, y: centerY }, ctx);
4555
+ ctx.store.update(id, {
4556
+ position: {
4557
+ x: snappedCenter.x - el.size.w / 2,
4558
+ y: snappedCenter.y - el.size.h / 2
4559
+ }
4560
+ });
4080
4561
  } else {
4081
4562
  ctx.store.update(id, {
4082
4563
  position: { x: el.position.x + dx, y: el.position.y + dy }
@@ -4151,6 +4632,11 @@ var SelectTool = class {
4151
4632
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
4152
4633
  return;
4153
4634
  }
4635
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4636
+ if (templateResizeHit) {
4637
+ ctx.setCursor?.("nwse-resize");
4638
+ return;
4639
+ }
4154
4640
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4155
4641
  if (resizeHit) {
4156
4642
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -4287,6 +4773,24 @@ var SelectTool = class {
4287
4773
  );
4288
4774
  }
4289
4775
  canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4776
+ } else if (el.type === "template") {
4777
+ canvasCtx.setLineDash([]);
4778
+ canvasCtx.fillStyle = "#ffffff";
4779
+ const hx = bounds.x + bounds.w;
4780
+ const hy = bounds.y + bounds.h;
4781
+ canvasCtx.fillRect(
4782
+ hx - handleWorldSize / 2,
4783
+ hy - handleWorldSize / 2,
4784
+ handleWorldSize,
4785
+ handleWorldSize
4786
+ );
4787
+ canvasCtx.strokeRect(
4788
+ hx - handleWorldSize / 2,
4789
+ hy - handleWorldSize / 2,
4790
+ handleWorldSize,
4791
+ handleWorldSize
4792
+ );
4793
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
4290
4794
  }
4291
4795
  }
4292
4796
  canvasCtx.restore();
@@ -4311,6 +4815,43 @@ var SelectTool = class {
4311
4815
  }
4312
4816
  canvasCtx.restore();
4313
4817
  }
4818
+ hitTestTemplateResizeHandle(world, ctx) {
4819
+ if (this._selectedIds.length === 0) return null;
4820
+ const zoom = ctx.camera.zoom;
4821
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
4822
+ for (const id of this._selectedIds) {
4823
+ const el = ctx.store.getById(id);
4824
+ if (!el || el.type !== "template") continue;
4825
+ const bounds = getElementBounds(el);
4826
+ if (!bounds) continue;
4827
+ const hx = bounds.x + bounds.w;
4828
+ const hy = bounds.y + bounds.h;
4829
+ if (Math.abs(world.x - hx) <= handleHalf && Math.abs(world.y - hy) <= handleHalf) {
4830
+ return id;
4831
+ }
4832
+ }
4833
+ return null;
4834
+ }
4835
+ handleTemplateResize(world, ctx) {
4836
+ if (this.mode.type !== "resizing-template") return;
4837
+ const el = ctx.store.getById(this.mode.elementId);
4838
+ if (!el || el.type !== "template" || el.locked) return;
4839
+ const dx = world.x - el.position.x;
4840
+ const dy = world.y - el.position.y;
4841
+ let newRadius = Math.sqrt(dx * dx + dy * dy);
4842
+ if (ctx.snapToGrid && ctx.gridSize && ctx.gridSize > 0) {
4843
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4844
+ newRadius = Math.max(snapUnit, Math.round(newRadius / snapUnit) * snapUnit);
4845
+ }
4846
+ newRadius = Math.max(MIN_ELEMENT_SIZE, newRadius);
4847
+ const updates = { radius: newRadius };
4848
+ if (el.feetPerCell != null && ctx.gridSize && ctx.gridSize > 0) {
4849
+ const snapUnit = ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize;
4850
+ updates.radiusFeet = newRadius / snapUnit * el.feetPerCell;
4851
+ }
4852
+ ctx.store.update(this.mode.elementId, updates);
4853
+ ctx.requestRender();
4854
+ }
4314
4855
  getMarqueeRect() {
4315
4856
  if (this.mode.type !== "marquee") return null;
4316
4857
  const { start } = this.mode;
@@ -4367,6 +4908,11 @@ var SelectTool = class {
4367
4908
  if (el.type === "arrow") {
4368
4909
  return isNearBezier(point, el.from, el.to, el.bend, 10);
4369
4910
  }
4911
+ if (el.type === "template") {
4912
+ const bounds = getElementBounds(el);
4913
+ if (!bounds) return false;
4914
+ return point.x >= bounds.x && point.x <= bounds.x + bounds.w && point.y >= bounds.y && point.y <= bounds.y + bounds.h;
4915
+ }
4370
4916
  return false;
4371
4917
  }
4372
4918
  };
@@ -4424,7 +4970,7 @@ var ArrowTool = class {
4424
4970
  this.fromBinding = { elementId: target.id };
4425
4971
  this.fromTarget = target;
4426
4972
  } else {
4427
- this.start = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
4973
+ this.start = smartSnap(world, ctx);
4428
4974
  this.fromBinding = void 0;
4429
4975
  this.fromTarget = null;
4430
4976
  }
@@ -4442,7 +4988,7 @@ var ArrowTool = class {
4442
4988
  this.end = getElementCenter(target);
4443
4989
  this.toTarget = target;
4444
4990
  } else {
4445
- this.end = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
4991
+ this.end = smartSnap(world, ctx);
4446
4992
  this.toTarget = null;
4447
4993
  }
4448
4994
  ctx.requestRender();
@@ -4555,9 +5101,7 @@ var NoteTool = class {
4555
5101
  }
4556
5102
  onPointerUp(state, ctx) {
4557
5103
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4558
- if (ctx.snapToGrid && ctx.gridSize) {
4559
- world = snapPoint(world, ctx.gridSize);
4560
- }
5104
+ world = smartSnap(world, ctx);
4561
5105
  const note = createNote({
4562
5106
  position: world,
4563
5107
  size: { ...this.size },
@@ -4612,9 +5156,7 @@ var TextTool = class {
4612
5156
  }
4613
5157
  onPointerUp(state, ctx) {
4614
5158
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4615
- if (ctx.snapToGrid && ctx.gridSize) {
4616
- world = snapPoint(world, ctx.gridSize);
4617
- }
5159
+ world = smartSnap(world, ctx);
4618
5160
  const textEl = createText({
4619
5161
  position: world,
4620
5162
  fontSize: this.fontSize,
@@ -4647,8 +5189,12 @@ var ImageTool = class {
4647
5189
  onPointerUp(state, ctx) {
4648
5190
  if (!this.src) return;
4649
5191
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5192
+ const snapped = smartSnap(world, ctx);
4650
5193
  const image = createImage({
4651
- position: world,
5194
+ position: {
5195
+ x: snapped.x - this.size.w / 2,
5196
+ y: snapped.y - this.size.h / 2
5197
+ },
4652
5198
  size: { ...this.size },
4653
5199
  src: this.src
4654
5200
  });
@@ -4785,7 +5331,7 @@ var ShapeTool = class {
4785
5331
  for (const listener of this.optionListeners) listener();
4786
5332
  }
4787
5333
  snap(point, ctx) {
4788
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
5334
+ return smartSnap(point, ctx);
4789
5335
  }
4790
5336
  onKeyDown = (e) => {
4791
5337
  if (e.key === "Shift") this.shiftHeld = true;
@@ -4795,6 +5341,398 @@ var ShapeTool = class {
4795
5341
  };
4796
5342
  };
4797
5343
 
5344
+ // src/tools/measure-tool.ts
5345
+ var MeasureTool = class {
5346
+ name = "measure";
5347
+ start = null;
5348
+ end = null;
5349
+ gridSize = 1;
5350
+ gridType;
5351
+ hexOrientation;
5352
+ feetPerCell;
5353
+ optionListeners = /* @__PURE__ */ new Set();
5354
+ constructor(options = {}) {
5355
+ this.feetPerCell = options.feetPerCell ?? 5;
5356
+ }
5357
+ getOptions() {
5358
+ return { feetPerCell: this.feetPerCell };
5359
+ }
5360
+ setOptions(options) {
5361
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5362
+ this.notifyOptionsChange();
5363
+ }
5364
+ onOptionsChange(listener) {
5365
+ this.optionListeners.add(listener);
5366
+ return () => this.optionListeners.delete(listener);
5367
+ }
5368
+ onPointerDown(state, ctx) {
5369
+ this.gridSize = ctx.gridSize ?? 1;
5370
+ this.gridType = ctx.gridType;
5371
+ this.hexOrientation = ctx.hexOrientation;
5372
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5373
+ this.start = this.snapToGrid(world, ctx);
5374
+ this.end = { ...this.start };
5375
+ }
5376
+ onPointerMove(state, ctx) {
5377
+ if (!this.start) return;
5378
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5379
+ this.end = this.snapToGrid(world, ctx);
5380
+ ctx.requestRender();
5381
+ }
5382
+ onPointerUp(_state, ctx) {
5383
+ if (!this.start) return;
5384
+ this.start = null;
5385
+ this.end = null;
5386
+ ctx.requestRender();
5387
+ }
5388
+ onDeactivate(_ctx) {
5389
+ this.start = null;
5390
+ this.end = null;
5391
+ }
5392
+ getMeasurement() {
5393
+ if (!this.start || !this.end) return null;
5394
+ const dx = this.end.x - this.start.x;
5395
+ const dy = this.end.y - this.start.y;
5396
+ const worldDistance = Math.sqrt(dx * dx + dy * dy);
5397
+ let cells;
5398
+ if (this.gridType === "hex" && this.hexOrientation) {
5399
+ cells = getHexDistance(this.start, this.end, this.gridSize, this.hexOrientation);
5400
+ } else {
5401
+ const snapUnit = this.gridSize;
5402
+ cells = worldDistance / snapUnit;
5403
+ }
5404
+ const feet = cells * this.feetPerCell;
5405
+ return {
5406
+ start: { ...this.start },
5407
+ end: { ...this.end },
5408
+ worldDistance,
5409
+ cells,
5410
+ feet
5411
+ };
5412
+ }
5413
+ renderOverlay(ctx) {
5414
+ const m = this.getMeasurement();
5415
+ if (!m) return;
5416
+ ctx.save();
5417
+ ctx.strokeStyle = "#FF5722";
5418
+ ctx.setLineDash([8, 4]);
5419
+ ctx.lineWidth = 2;
5420
+ ctx.beginPath();
5421
+ ctx.moveTo(m.start.x, m.start.y);
5422
+ ctx.lineTo(m.end.x, m.end.y);
5423
+ ctx.stroke();
5424
+ ctx.setLineDash([]);
5425
+ ctx.fillStyle = "#FF5722";
5426
+ const dotRadius = 4;
5427
+ ctx.beginPath();
5428
+ ctx.arc(m.start.x, m.start.y, dotRadius, 0, Math.PI * 2);
5429
+ ctx.fill();
5430
+ ctx.beginPath();
5431
+ ctx.arc(m.end.x, m.end.y, dotRadius, 0, Math.PI * 2);
5432
+ ctx.fill();
5433
+ const label = `${Math.round(m.feet)} ft`;
5434
+ const midX = (m.start.x + m.end.x) / 2;
5435
+ const midY = (m.start.y + m.end.y) / 2;
5436
+ ctx.font = "14px sans-serif";
5437
+ const metrics = ctx.measureText(label);
5438
+ const padX = 6;
5439
+ const padY = 4;
5440
+ const textH = 14;
5441
+ ctx.fillStyle = "rgba(0, 0, 0, 0.75)";
5442
+ ctx.beginPath();
5443
+ ctx.roundRect(
5444
+ midX - metrics.width / 2 - padX,
5445
+ midY - textH / 2 - padY,
5446
+ metrics.width + padX * 2,
5447
+ textH + padY * 2,
5448
+ 4
5449
+ );
5450
+ ctx.fill();
5451
+ ctx.fillStyle = "#FFFFFF";
5452
+ ctx.textAlign = "center";
5453
+ ctx.textBaseline = "middle";
5454
+ ctx.fillText(label, midX, midY);
5455
+ ctx.restore();
5456
+ }
5457
+ snapToGrid(point, ctx) {
5458
+ if (!ctx.gridSize) return point;
5459
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5460
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5461
+ }
5462
+ if (ctx.gridType === "square") {
5463
+ return snapPoint(point, ctx.gridSize);
5464
+ }
5465
+ if (ctx.snapToGrid) {
5466
+ return snapPoint(point, ctx.gridSize);
5467
+ }
5468
+ return point;
5469
+ }
5470
+ notifyOptionsChange() {
5471
+ for (const listener of this.optionListeners) listener();
5472
+ }
5473
+ };
5474
+
5475
+ // src/tools/template-tool.ts
5476
+ var TemplateTool = class {
5477
+ name = "template";
5478
+ drawing = false;
5479
+ origin = { x: 0, y: 0 };
5480
+ current = { x: 0, y: 0 };
5481
+ gridSize = 1;
5482
+ gridType;
5483
+ hexOrientation;
5484
+ snapEnabled = false;
5485
+ templateShape;
5486
+ fillColor;
5487
+ strokeColor;
5488
+ strokeWidth;
5489
+ opacity;
5490
+ feetPerCell;
5491
+ optionListeners = /* @__PURE__ */ new Set();
5492
+ constructor(options = {}) {
5493
+ this.templateShape = options.templateShape ?? "circle";
5494
+ this.fillColor = options.fillColor ?? "rgba(255, 87, 34, 0.2)";
5495
+ this.strokeColor = options.strokeColor ?? "#FF5722";
5496
+ this.strokeWidth = options.strokeWidth ?? 2;
5497
+ this.opacity = options.opacity ?? 0.6;
5498
+ this.feetPerCell = options.feetPerCell ?? 5;
5499
+ }
5500
+ getOptions() {
5501
+ return {
5502
+ templateShape: this.templateShape,
5503
+ fillColor: this.fillColor,
5504
+ strokeColor: this.strokeColor,
5505
+ strokeWidth: this.strokeWidth,
5506
+ opacity: this.opacity,
5507
+ feetPerCell: this.feetPerCell
5508
+ };
5509
+ }
5510
+ setOptions(options) {
5511
+ if (options.templateShape !== void 0) this.templateShape = options.templateShape;
5512
+ if (options.fillColor !== void 0) this.fillColor = options.fillColor;
5513
+ if (options.strokeColor !== void 0) this.strokeColor = options.strokeColor;
5514
+ if (options.strokeWidth !== void 0) this.strokeWidth = options.strokeWidth;
5515
+ if (options.opacity !== void 0) this.opacity = options.opacity;
5516
+ if (options.feetPerCell !== void 0) this.feetPerCell = options.feetPerCell;
5517
+ this.notifyOptionsChange();
5518
+ }
5519
+ onOptionsChange(listener) {
5520
+ this.optionListeners.add(listener);
5521
+ return () => this.optionListeners.delete(listener);
5522
+ }
5523
+ onPointerDown(state, ctx) {
5524
+ this.drawing = true;
5525
+ this.gridSize = ctx.gridSize ?? 1;
5526
+ this.gridType = ctx.gridType;
5527
+ this.hexOrientation = ctx.hexOrientation;
5528
+ this.snapEnabled = !!ctx.gridType || (ctx.snapToGrid ?? false);
5529
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5530
+ this.origin = this.snapToGrid(world, ctx);
5531
+ this.current = { ...this.origin };
5532
+ }
5533
+ onPointerMove(state, ctx) {
5534
+ if (!this.drawing) return;
5535
+ this.current = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5536
+ ctx.requestRender();
5537
+ }
5538
+ onPointerUp(_state, ctx) {
5539
+ if (!this.drawing) return;
5540
+ this.drawing = false;
5541
+ const radius = this.computeRadius();
5542
+ if (radius <= 0) return;
5543
+ const angle = this.computeAngle();
5544
+ const gridSize = ctx.gridSize;
5545
+ const snapUnit = gridSize && gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * gridSize : gridSize : 0;
5546
+ const cells = snapUnit > 0 ? radius / snapUnit : 0;
5547
+ const radiusFeet = cells * this.feetPerCell;
5548
+ const element = createTemplate({
5549
+ position: { ...this.origin },
5550
+ templateShape: this.templateShape,
5551
+ radius,
5552
+ angle,
5553
+ fillColor: this.fillColor,
5554
+ strokeColor: this.strokeColor,
5555
+ strokeWidth: this.strokeWidth,
5556
+ opacity: this.opacity,
5557
+ feetPerCell: this.feetPerCell,
5558
+ radiusFeet: radiusFeet > 0 ? radiusFeet : void 0,
5559
+ layerId: ctx.activeLayerId ?? ""
5560
+ });
5561
+ ctx.store.add(element);
5562
+ ctx.requestRender();
5563
+ ctx.switchTool?.("select");
5564
+ }
5565
+ onDeactivate(_ctx) {
5566
+ this.drawing = false;
5567
+ this.origin = { x: 0, y: 0 };
5568
+ this.current = { x: 0, y: 0 };
5569
+ }
5570
+ renderOverlay(ctx) {
5571
+ if (!this.drawing) return;
5572
+ const radius = this.computeRadius();
5573
+ if (radius <= 0) return;
5574
+ if (this.gridType === "hex" && this.hexOrientation) {
5575
+ this.renderHexOverlay(ctx, radius);
5576
+ return;
5577
+ }
5578
+ this.renderGeometricOverlay(ctx, radius);
5579
+ }
5580
+ renderGeometricOverlay(ctx, radius) {
5581
+ const cx = this.origin.x;
5582
+ const cy = this.origin.y;
5583
+ const angle = this.computeAngle();
5584
+ ctx.save();
5585
+ ctx.globalAlpha = 0.4;
5586
+ ctx.fillStyle = this.fillColor;
5587
+ ctx.strokeStyle = this.strokeColor;
5588
+ ctx.lineWidth = this.strokeWidth;
5589
+ switch (this.templateShape) {
5590
+ case "circle":
5591
+ ctx.beginPath();
5592
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
5593
+ ctx.fill();
5594
+ ctx.stroke();
5595
+ break;
5596
+ case "square":
5597
+ ctx.fillRect(cx - radius / 2, cy - radius / 2, radius, radius);
5598
+ ctx.strokeRect(cx - radius / 2, cy - radius / 2, radius, radius);
5599
+ break;
5600
+ case "cone": {
5601
+ const halfAngle = Math.atan(0.5);
5602
+ ctx.beginPath();
5603
+ ctx.moveTo(cx, cy);
5604
+ ctx.arc(cx, cy, radius, angle - halfAngle, angle + halfAngle);
5605
+ ctx.closePath();
5606
+ ctx.fill();
5607
+ ctx.stroke();
5608
+ break;
5609
+ }
5610
+ case "line": {
5611
+ const halfW = radius / 12;
5612
+ const cos = Math.cos(angle);
5613
+ const sin = Math.sin(angle);
5614
+ const perpX = -sin * halfW;
5615
+ const perpY = cos * halfW;
5616
+ ctx.beginPath();
5617
+ ctx.moveTo(cx + perpX, cy + perpY);
5618
+ ctx.lineTo(cx + radius * cos + perpX, cy + radius * sin + perpY);
5619
+ ctx.lineTo(cx + radius * cos - perpX, cy + radius * sin - perpY);
5620
+ ctx.lineTo(cx - perpX, cy - perpY);
5621
+ ctx.closePath();
5622
+ ctx.fill();
5623
+ ctx.stroke();
5624
+ break;
5625
+ }
5626
+ }
5627
+ ctx.restore();
5628
+ }
5629
+ renderHexOverlay(ctx, radius) {
5630
+ const orientation = this.hexOrientation;
5631
+ if (!orientation) return;
5632
+ const cellSize = this.gridSize;
5633
+ const snapUnit = Math.sqrt(3) * cellSize;
5634
+ const radiusCells = radius / snapUnit;
5635
+ const angle = this.computeAngle();
5636
+ const center = this.origin;
5637
+ let hexCells;
5638
+ switch (this.templateShape) {
5639
+ case "circle":
5640
+ hexCells = getHexCellsInRadius(center, radiusCells, cellSize, orientation);
5641
+ break;
5642
+ case "cone":
5643
+ hexCells = getHexCellsInCone(center, angle, radiusCells, cellSize, orientation);
5644
+ break;
5645
+ case "line":
5646
+ hexCells = getHexCellsInLine(center, angle, radiusCells, cellSize, orientation);
5647
+ break;
5648
+ case "square":
5649
+ hexCells = getHexCellsInSquare(center, radiusCells, cellSize, orientation);
5650
+ break;
5651
+ }
5652
+ ctx.save();
5653
+ ctx.globalAlpha = 0.4;
5654
+ ctx.beginPath();
5655
+ for (const cell of hexCells) {
5656
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5657
+ }
5658
+ ctx.fillStyle = this.fillColor;
5659
+ ctx.fill();
5660
+ ctx.beginPath();
5661
+ for (const cell of hexCells) {
5662
+ drawHexPath(ctx, cell.x, cell.y, cellSize, orientation);
5663
+ }
5664
+ ctx.strokeStyle = this.strokeColor;
5665
+ ctx.lineWidth = this.strokeWidth;
5666
+ ctx.stroke();
5667
+ if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
5668
+ ctx.globalAlpha = 0.5;
5669
+ ctx.beginPath();
5670
+ drawHexPath(ctx, center.x, center.y, cellSize, orientation);
5671
+ ctx.fillStyle = this.strokeColor;
5672
+ ctx.fill();
5673
+ ctx.strokeStyle = this.strokeColor;
5674
+ ctx.lineWidth = this.strokeWidth;
5675
+ ctx.stroke();
5676
+ }
5677
+ if (this.templateShape === "circle") {
5678
+ const feet = radiusCells * this.feetPerCell;
5679
+ if (feet > 0) {
5680
+ ctx.globalAlpha = 1;
5681
+ const label = `${Math.round(feet)} ft`;
5682
+ const fontSize = Math.max(10, Math.min(14, radius * 0.15));
5683
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
5684
+ ctx.textAlign = "center";
5685
+ ctx.textBaseline = "bottom";
5686
+ const textX = center.x;
5687
+ const textY = center.y - 4;
5688
+ const metrics = ctx.measureText(label);
5689
+ const padX = 4;
5690
+ const padY = 2;
5691
+ const textW = metrics.width + padX * 2;
5692
+ const textH = fontSize + padY * 2;
5693
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
5694
+ ctx.beginPath();
5695
+ ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
5696
+ ctx.fill();
5697
+ ctx.fillStyle = this.strokeColor;
5698
+ ctx.fillText(label, textX, textY - padY);
5699
+ }
5700
+ }
5701
+ ctx.restore();
5702
+ }
5703
+ computeRadius() {
5704
+ const dx = this.current.x - this.origin.x;
5705
+ const dy = this.current.y - this.origin.y;
5706
+ const raw = Math.sqrt(dx * dx + dy * dy);
5707
+ if (this.snapEnabled && this.gridSize > 0) {
5708
+ const snapUnit = this.gridType === "hex" ? Math.sqrt(3) * this.gridSize : this.gridSize;
5709
+ return Math.max(snapUnit, Math.round(raw / snapUnit) * snapUnit);
5710
+ }
5711
+ return raw;
5712
+ }
5713
+ computeAngle() {
5714
+ const dx = this.current.x - this.origin.x;
5715
+ const dy = this.current.y - this.origin.y;
5716
+ return Math.atan2(dy, dx);
5717
+ }
5718
+ snapToGrid(point, ctx) {
5719
+ if (!ctx.gridSize) return point;
5720
+ if (ctx.gridType === "hex" && ctx.hexOrientation) {
5721
+ return snapToHexCenter(point, ctx.gridSize, ctx.hexOrientation);
5722
+ }
5723
+ if (ctx.gridType === "square") {
5724
+ return snapPoint(point, ctx.gridSize);
5725
+ }
5726
+ if (ctx.snapToGrid) {
5727
+ return snapPoint(point, ctx.gridSize);
5728
+ }
5729
+ return point;
5730
+ }
5731
+ notifyOptionsChange() {
5732
+ for (const listener of this.optionListeners) listener();
5733
+ }
5734
+ };
5735
+
4798
5736
  // src/history/layer-commands.ts
4799
5737
  var CreateLayerCommand = class {
4800
5738
  constructor(manager, layer) {
@@ -4836,7 +5774,7 @@ var UpdateLayerCommand = class {
4836
5774
  };
4837
5775
 
4838
5776
  // src/index.ts
4839
- var VERSION = "0.8.11";
5777
+ var VERSION = "0.9.0";
4840
5778
  export {
4841
5779
  AddElementCommand,
4842
5780
  ArrowTool,
@@ -4855,6 +5793,7 @@ export {
4855
5793
  ImageTool,
4856
5794
  InputHandler,
4857
5795
  LayerManager,
5796
+ MeasureTool,
4858
5797
  NoteEditor,
4859
5798
  NoteTool,
4860
5799
  PencilTool,
@@ -4863,6 +5802,7 @@ export {
4863
5802
  RemoveLayerCommand,
4864
5803
  SelectTool,
4865
5804
  ShapeTool,
5805
+ TemplateTool,
4866
5806
  TextTool,
4867
5807
  ToolManager,
4868
5808
  UpdateElementCommand,
@@ -4879,7 +5819,9 @@ export {
4879
5819
  createNote,
4880
5820
  createShape,
4881
5821
  createStroke,
5822
+ createTemplate,
4882
5823
  createText,
5824
+ drawHexPath,
4883
5825
  exportImage,
4884
5826
  exportState,
4885
5827
  findBindTarget,
@@ -4892,10 +5834,17 @@ export {
4892
5834
  getEdgeIntersection,
4893
5835
  getElementBounds,
4894
5836
  getElementCenter,
5837
+ getHexCellsInCone,
5838
+ getHexCellsInLine,
5839
+ getHexCellsInRadius,
5840
+ getHexCellsInSquare,
5841
+ getHexDistance,
4895
5842
  isBindable,
4896
5843
  isNearBezier,
4897
5844
  parseState,
5845
+ smartSnap,
4898
5846
  snapPoint,
5847
+ snapToHexCenter,
4899
5848
  unbindArrow,
4900
5849
  updateBoundArrow
4901
5850
  };