@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.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);
@@ -3217,7 +3660,10 @@ var Viewport = class {
3217
3660
  this.renderer = new ElementRenderer();
3218
3661
  this.renderer.setStore(this.store);
3219
3662
  this.renderer.setCamera(this.camera);
3220
- this.renderer.setOnImageLoad(() => this.requestRender());
3663
+ this.renderer.setOnImageLoad(() => {
3664
+ this.renderLoop.markAllLayersDirty();
3665
+ this.requestRender();
3666
+ });
3221
3667
  this.noteEditor = new NoteEditor();
3222
3668
  this.noteEditor.setOnStop((id) => this.onTextEditStop(id));
3223
3669
  this.history = new HistoryStack();
@@ -3278,16 +3724,19 @@ var Viewport = class {
3278
3724
  });
3279
3725
  this.unsubStore = [
3280
3726
  this.store.on("add", (el) => {
3727
+ if (el.type === "grid") this.syncGridContext();
3281
3728
  this.renderLoop.markLayerDirty(el.layerId);
3282
3729
  this.requestRender();
3283
3730
  }),
3284
3731
  this.store.on("remove", (el) => {
3732
+ if (el.type === "grid") this.syncGridContext();
3285
3733
  this.unbindArrowsFrom(el);
3286
3734
  this.domNodeManager.removeDomNode(el.id);
3287
3735
  this.renderLoop.markLayerDirty(el.layerId);
3288
3736
  this.requestRender();
3289
3737
  }),
3290
3738
  this.store.on("update", ({ previous, current }) => {
3739
+ if (current.type === "grid") this.syncGridContext();
3291
3740
  this.renderLoop.markLayerDirty(current.layerId);
3292
3741
  if (previous.layerId !== current.layerId) {
3293
3742
  this.renderLoop.markLayerDirty(previous.layerId);
@@ -3297,6 +3746,7 @@ var Viewport = class {
3297
3746
  this.store.on("clear", () => {
3298
3747
  this.domNodeManager.clearDomNodes();
3299
3748
  this.renderLoop.markAllLayersDirty();
3749
+ this.syncGridContext();
3300
3750
  this.requestRender();
3301
3751
  })
3302
3752
  ];
@@ -3310,6 +3760,7 @@ var Viewport = class {
3310
3760
  this.observeResize();
3311
3761
  this.syncCanvasSize();
3312
3762
  this.renderLoop.start();
3763
+ this.syncGridContext();
3313
3764
  }
3314
3765
  camera;
3315
3766
  store;
@@ -3626,6 +4077,18 @@ var Viewport = class {
3626
4077
  this.renderLoop.setCanvasSize(rect.width * dpr, rect.height * dpr);
3627
4078
  this.requestRender();
3628
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
+ }
3629
4092
  observeResize() {
3630
4093
  if (typeof ResizeObserver === "undefined") return;
3631
4094
  this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
@@ -4000,7 +4463,7 @@ var SelectTool = class {
4000
4463
  ctx.setCursor?.("default");
4001
4464
  }
4002
4465
  snap(point, ctx) {
4003
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
4466
+ return smartSnap(point, ctx);
4004
4467
  }
4005
4468
  onPointerDown(state, ctx) {
4006
4469
  this.ctx = ctx;
@@ -4017,6 +4480,12 @@ var SelectTool = class {
4017
4480
  ctx.requestRender();
4018
4481
  return;
4019
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
+ }
4020
4489
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4021
4490
  if (resizeHit) {
4022
4491
  const el = ctx.store.getById(resizeHit.elementId);
@@ -4051,6 +4520,11 @@ var SelectTool = class {
4051
4520
  applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
4052
4521
  return;
4053
4522
  }
4523
+ if (this.mode.type === "resizing-template") {
4524
+ ctx.setCursor?.("nwse-resize");
4525
+ this.handleTemplateResize(world, ctx);
4526
+ return;
4527
+ }
4054
4528
  if (this.mode.type === "resizing") {
4055
4529
  ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
4056
4530
  this.handleResize(world, ctx);
@@ -4074,6 +4548,16 @@ var SelectTool = class {
4074
4548
  from: { x: el.from.x + dx, y: el.from.y + dy },
4075
4549
  to: { x: el.to.x + dx, y: el.to.y + dy }
4076
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
+ });
4077
4561
  } else {
4078
4562
  ctx.store.update(id, {
4079
4563
  position: { x: el.position.x + dx, y: el.position.y + dy }
@@ -4148,6 +4632,11 @@ var SelectTool = class {
4148
4632
  ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
4149
4633
  return;
4150
4634
  }
4635
+ const templateResizeHit = this.hitTestTemplateResizeHandle(world, ctx);
4636
+ if (templateResizeHit) {
4637
+ ctx.setCursor?.("nwse-resize");
4638
+ return;
4639
+ }
4151
4640
  const resizeHit = this.hitTestResizeHandle(world, ctx);
4152
4641
  if (resizeHit) {
4153
4642
  ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
@@ -4284,6 +4773,24 @@ var SelectTool = class {
4284
4773
  );
4285
4774
  }
4286
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]);
4287
4794
  }
4288
4795
  }
4289
4796
  canvasCtx.restore();
@@ -4308,6 +4815,43 @@ var SelectTool = class {
4308
4815
  }
4309
4816
  canvasCtx.restore();
4310
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
+ }
4311
4855
  getMarqueeRect() {
4312
4856
  if (this.mode.type !== "marquee") return null;
4313
4857
  const { start } = this.mode;
@@ -4364,6 +4908,11 @@ var SelectTool = class {
4364
4908
  if (el.type === "arrow") {
4365
4909
  return isNearBezier(point, el.from, el.to, el.bend, 10);
4366
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
+ }
4367
4916
  return false;
4368
4917
  }
4369
4918
  };
@@ -4421,7 +4970,7 @@ var ArrowTool = class {
4421
4970
  this.fromBinding = { elementId: target.id };
4422
4971
  this.fromTarget = target;
4423
4972
  } else {
4424
- this.start = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
4973
+ this.start = smartSnap(world, ctx);
4425
4974
  this.fromBinding = void 0;
4426
4975
  this.fromTarget = null;
4427
4976
  }
@@ -4439,7 +4988,7 @@ var ArrowTool = class {
4439
4988
  this.end = getElementCenter(target);
4440
4989
  this.toTarget = target;
4441
4990
  } else {
4442
- this.end = ctx.snapToGrid && ctx.gridSize ? snapPoint(world, ctx.gridSize) : world;
4991
+ this.end = smartSnap(world, ctx);
4443
4992
  this.toTarget = null;
4444
4993
  }
4445
4994
  ctx.requestRender();
@@ -4552,9 +5101,7 @@ var NoteTool = class {
4552
5101
  }
4553
5102
  onPointerUp(state, ctx) {
4554
5103
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4555
- if (ctx.snapToGrid && ctx.gridSize) {
4556
- world = snapPoint(world, ctx.gridSize);
4557
- }
5104
+ world = smartSnap(world, ctx);
4558
5105
  const note = createNote({
4559
5106
  position: world,
4560
5107
  size: { ...this.size },
@@ -4609,9 +5156,7 @@ var TextTool = class {
4609
5156
  }
4610
5157
  onPointerUp(state, ctx) {
4611
5158
  let world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
4612
- if (ctx.snapToGrid && ctx.gridSize) {
4613
- world = snapPoint(world, ctx.gridSize);
4614
- }
5159
+ world = smartSnap(world, ctx);
4615
5160
  const textEl = createText({
4616
5161
  position: world,
4617
5162
  fontSize: this.fontSize,
@@ -4644,8 +5189,12 @@ var ImageTool = class {
4644
5189
  onPointerUp(state, ctx) {
4645
5190
  if (!this.src) return;
4646
5191
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
5192
+ const snapped = smartSnap(world, ctx);
4647
5193
  const image = createImage({
4648
- position: world,
5194
+ position: {
5195
+ x: snapped.x - this.size.w / 2,
5196
+ y: snapped.y - this.size.h / 2
5197
+ },
4649
5198
  size: { ...this.size },
4650
5199
  src: this.src
4651
5200
  });
@@ -4782,7 +5331,7 @@ var ShapeTool = class {
4782
5331
  for (const listener of this.optionListeners) listener();
4783
5332
  }
4784
5333
  snap(point, ctx) {
4785
- return ctx.snapToGrid && ctx.gridSize ? snapPoint(point, ctx.gridSize) : point;
5334
+ return smartSnap(point, ctx);
4786
5335
  }
4787
5336
  onKeyDown = (e) => {
4788
5337
  if (e.key === "Shift") this.shiftHeld = true;
@@ -4792,6 +5341,398 @@ var ShapeTool = class {
4792
5341
  };
4793
5342
  };
4794
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
+
4795
5736
  // src/history/layer-commands.ts
4796
5737
  var CreateLayerCommand = class {
4797
5738
  constructor(manager, layer) {
@@ -4833,7 +5774,7 @@ var UpdateLayerCommand = class {
4833
5774
  };
4834
5775
 
4835
5776
  // src/index.ts
4836
- var VERSION = "0.8.10";
5777
+ var VERSION = "0.9.0";
4837
5778
  export {
4838
5779
  AddElementCommand,
4839
5780
  ArrowTool,
@@ -4852,6 +5793,7 @@ export {
4852
5793
  ImageTool,
4853
5794
  InputHandler,
4854
5795
  LayerManager,
5796
+ MeasureTool,
4855
5797
  NoteEditor,
4856
5798
  NoteTool,
4857
5799
  PencilTool,
@@ -4860,6 +5802,7 @@ export {
4860
5802
  RemoveLayerCommand,
4861
5803
  SelectTool,
4862
5804
  ShapeTool,
5805
+ TemplateTool,
4863
5806
  TextTool,
4864
5807
  ToolManager,
4865
5808
  UpdateElementCommand,
@@ -4876,7 +5819,9 @@ export {
4876
5819
  createNote,
4877
5820
  createShape,
4878
5821
  createStroke,
5822
+ createTemplate,
4879
5823
  createText,
5824
+ drawHexPath,
4880
5825
  exportImage,
4881
5826
  exportState,
4882
5827
  findBindTarget,
@@ -4889,10 +5834,17 @@ export {
4889
5834
  getEdgeIntersection,
4890
5835
  getElementBounds,
4891
5836
  getElementCenter,
5837
+ getHexCellsInCone,
5838
+ getHexCellsInLine,
5839
+ getHexCellsInRadius,
5840
+ getHexCellsInSquare,
5841
+ getHexDistance,
4892
5842
  isBindable,
4893
5843
  isNearBezier,
4894
5844
  parseState,
5845
+ smartSnap,
4895
5846
  snapPoint,
5847
+ snapToHexCenter,
4896
5848
  unbindArrow,
4897
5849
  updateBoundArrow
4898
5850
  };