@fieldnotes/core 0.20.0 → 0.22.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
@@ -1933,10 +1933,134 @@ function getTemplateBounds(el) {
1933
1933
  }
1934
1934
  }
1935
1935
  }
1936
+ function transferStrokeBounds(prev, next) {
1937
+ if (prev.type !== "stroke" || next.type !== "stroke") return;
1938
+ if (prev.points !== next.points) return;
1939
+ if (prev.position.x !== next.position.x || prev.position.y !== next.position.y) return;
1940
+ const bounds = strokeBoundsCache.get(prev);
1941
+ if (bounds) strokeBoundsCache.set(next, bounds);
1942
+ }
1936
1943
  function boundsIntersect(a, b) {
1937
1944
  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;
1938
1945
  }
1939
1946
 
1947
+ // src/elements/stroke-smoothing.ts
1948
+ var MIN_PRESSURE_SCALE = 0.2;
1949
+ function pressureToWidth(pressure, baseWidth) {
1950
+ return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
1951
+ }
1952
+ function simplifyPoints(points, tolerance) {
1953
+ if (points.length <= 2) return points.slice();
1954
+ return rdp(points, 0, points.length - 1, tolerance);
1955
+ }
1956
+ function rdp(points, start, end, tolerance) {
1957
+ const first = points[start];
1958
+ const last = points[end];
1959
+ if (!first || !last) return [];
1960
+ if (end - start <= 1) return [first, last];
1961
+ let maxDist = 0;
1962
+ let maxIndex = start;
1963
+ for (let i = start + 1; i < end; i++) {
1964
+ const pt = points[i];
1965
+ if (!pt) continue;
1966
+ const dist = perpendicularDistance(pt, first, last);
1967
+ if (dist > maxDist) {
1968
+ maxDist = dist;
1969
+ maxIndex = i;
1970
+ }
1971
+ }
1972
+ if (maxDist <= tolerance) return [first, last];
1973
+ const left = rdp(points, start, maxIndex, tolerance);
1974
+ const right = rdp(points, maxIndex, end, tolerance);
1975
+ return left.concat(right.slice(1));
1976
+ }
1977
+ function perpendicularDistance(pt, lineStart, lineEnd) {
1978
+ const dx = lineEnd.x - lineStart.x;
1979
+ const dy = lineEnd.y - lineStart.y;
1980
+ const lenSq = dx * dx + dy * dy;
1981
+ if (lenSq === 0) {
1982
+ const ex = pt.x - lineStart.x;
1983
+ const ey = pt.y - lineStart.y;
1984
+ return Math.sqrt(ex * ex + ey * ey);
1985
+ }
1986
+ const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
1987
+ return num / Math.sqrt(lenSq);
1988
+ }
1989
+ function smoothToSegments(points) {
1990
+ if (points.length < 2) return [];
1991
+ if (points.length === 2) {
1992
+ const p0 = points[0];
1993
+ const p1 = points[1];
1994
+ if (!p0 || !p1) return [];
1995
+ const mx = (p0.x + p1.x) / 2;
1996
+ const my = (p0.y + p1.y) / 2;
1997
+ return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
1998
+ }
1999
+ const segments = [];
2000
+ const n = points.length;
2001
+ for (let i = 0; i < n - 1; i++) {
2002
+ const p0 = points[Math.max(0, i - 1)];
2003
+ const p1 = points[i];
2004
+ const p2 = points[i + 1];
2005
+ const p3 = points[Math.min(n - 1, i + 2)];
2006
+ if (!p0 || !p1 || !p2 || !p3) continue;
2007
+ const cp1 = {
2008
+ x: p1.x + (p2.x - p0.x) / 6,
2009
+ y: p1.y + (p2.y - p0.y) / 6
2010
+ };
2011
+ const cp2 = {
2012
+ x: p2.x - (p3.x - p1.x) / 6,
2013
+ y: p2.y - (p3.y - p1.y) / 6
2014
+ };
2015
+ segments.push({ start: p1, cp1, cp2, end: p2 });
2016
+ }
2017
+ return segments;
2018
+ }
2019
+
2020
+ // src/elements/stroke-cache.ts
2021
+ var cache = /* @__PURE__ */ new WeakMap();
2022
+ var WIDTH_QUANTUM = 0.25;
2023
+ function buildWidthBuckets(segments, widths) {
2024
+ if (typeof Path2D === "undefined") return null;
2025
+ const byWidth = /* @__PURE__ */ new Map();
2026
+ for (let i = 0; i < segments.length; i++) {
2027
+ const seg = segments[i];
2028
+ const w = widths[i];
2029
+ if (!seg || w === void 0) continue;
2030
+ const q = Math.max(WIDTH_QUANTUM, Math.round(w / WIDTH_QUANTUM) * WIDTH_QUANTUM);
2031
+ let path = byWidth.get(q);
2032
+ if (!path) {
2033
+ path = new Path2D();
2034
+ byWidth.set(q, path);
2035
+ }
2036
+ path.moveTo(seg.start.x, seg.start.y);
2037
+ path.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
2038
+ }
2039
+ return [...byWidth.entries()].map(([width, path]) => ({ width, path }));
2040
+ }
2041
+ function computeStrokeSegments(stroke) {
2042
+ const segments = smoothToSegments(stroke.points);
2043
+ const widths = [];
2044
+ for (const seg of segments) {
2045
+ const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
2046
+ widths.push(w);
2047
+ }
2048
+ const data = { segments, widths, buckets: buildWidthBuckets(segments, widths) };
2049
+ cache.set(stroke, data);
2050
+ return data;
2051
+ }
2052
+ function getStrokeRenderData(stroke) {
2053
+ const cached = cache.get(stroke);
2054
+ if (cached) return cached;
2055
+ return computeStrokeSegments(stroke);
2056
+ }
2057
+ function transferStrokeRenderData(prev, next) {
2058
+ if (prev.type !== "stroke" || next.type !== "stroke") return;
2059
+ if (prev.points !== next.points) return;
2060
+ const data = cache.get(prev);
2061
+ if (data) cache.set(next, data);
2062
+ }
2063
+
1940
2064
  // src/elements/element-store.ts
1941
2065
  var ElementStore = class {
1942
2066
  elements = /* @__PURE__ */ new Map();
@@ -1987,6 +2111,10 @@ var ElementStore = class {
1987
2111
  this.sortedCache = null;
1988
2112
  this._versions.set(id, (this._versions.get(id) ?? 0) + 1);
1989
2113
  const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
2114
+ if (updated.type === "stroke" && existing.type === "stroke") {
2115
+ transferStrokeRenderData(existing, updated);
2116
+ transferStrokeBounds(existing, updated);
2117
+ }
1990
2118
  if (updated.type === "arrow") {
1991
2119
  const arrow = updated;
1992
2120
  arrow.cachedControlPoint = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
@@ -2030,6 +2158,12 @@ var ElementStore = class {
2030
2158
  this._versions.set(el.id, 0);
2031
2159
  const bounds = getElementBounds(el);
2032
2160
  if (bounds) this.spatialIndex.insert(el.id, bounds);
2161
+ if (el.type === "stroke") {
2162
+ computeStrokeSegments(el);
2163
+ }
2164
+ if (el.type === "arrow" && el.bend !== 0 && !el.cachedControlPoint) {
2165
+ el.cachedControlPoint = getArrowControlPoint(el.from, el.to, el.bend);
2166
+ }
2033
2167
  }
2034
2168
  this.bus.emit("clear", null);
2035
2169
  for (const el of elements) {
@@ -2115,6 +2249,20 @@ var ElementStore = class {
2115
2249
  }
2116
2250
  };
2117
2251
 
2252
+ // src/elements/arrow-render-cache.ts
2253
+ var cache2 = /* @__PURE__ */ new WeakMap();
2254
+ function getArrowRenderGeometry(arrow) {
2255
+ const hit = cache2.get(arrow);
2256
+ if (hit) return hit;
2257
+ const geometry = {
2258
+ controlPoint: arrow.bend !== 0 ? arrow.cachedControlPoint ?? getArrowControlPoint(arrow.from, arrow.to, arrow.bend) : null,
2259
+ tangentStart: getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0),
2260
+ tangentEnd: getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1)
2261
+ };
2262
+ cache2.set(arrow, geometry);
2263
+ return geometry;
2264
+ }
2265
+
2118
2266
  // src/elements/arrow-binding.ts
2119
2267
  var BINDABLE_TYPES = /* @__PURE__ */ new Set(["note", "text", "image", "html", "shape"]);
2120
2268
  function isBindable(element) {
@@ -2235,98 +2383,6 @@ function unbindArrow(arrow, store) {
2235
2383
  return updates;
2236
2384
  }
2237
2385
 
2238
- // src/elements/stroke-smoothing.ts
2239
- var MIN_PRESSURE_SCALE = 0.2;
2240
- function pressureToWidth(pressure, baseWidth) {
2241
- return baseWidth * (MIN_PRESSURE_SCALE + (1 - MIN_PRESSURE_SCALE) * pressure);
2242
- }
2243
- function simplifyPoints(points, tolerance) {
2244
- if (points.length <= 2) return points.slice();
2245
- return rdp(points, 0, points.length - 1, tolerance);
2246
- }
2247
- function rdp(points, start, end, tolerance) {
2248
- const first = points[start];
2249
- const last = points[end];
2250
- if (!first || !last) return [];
2251
- if (end - start <= 1) return [first, last];
2252
- let maxDist = 0;
2253
- let maxIndex = start;
2254
- for (let i = start + 1; i < end; i++) {
2255
- const pt = points[i];
2256
- if (!pt) continue;
2257
- const dist = perpendicularDistance(pt, first, last);
2258
- if (dist > maxDist) {
2259
- maxDist = dist;
2260
- maxIndex = i;
2261
- }
2262
- }
2263
- if (maxDist <= tolerance) return [first, last];
2264
- const left = rdp(points, start, maxIndex, tolerance);
2265
- const right = rdp(points, maxIndex, end, tolerance);
2266
- return left.concat(right.slice(1));
2267
- }
2268
- function perpendicularDistance(pt, lineStart, lineEnd) {
2269
- const dx = lineEnd.x - lineStart.x;
2270
- const dy = lineEnd.y - lineStart.y;
2271
- const lenSq = dx * dx + dy * dy;
2272
- if (lenSq === 0) {
2273
- const ex = pt.x - lineStart.x;
2274
- const ey = pt.y - lineStart.y;
2275
- return Math.sqrt(ex * ex + ey * ey);
2276
- }
2277
- const num = Math.abs(dy * pt.x - dx * pt.y + lineEnd.x * lineStart.y - lineEnd.y * lineStart.x);
2278
- return num / Math.sqrt(lenSq);
2279
- }
2280
- function smoothToSegments(points) {
2281
- if (points.length < 2) return [];
2282
- if (points.length === 2) {
2283
- const p0 = points[0];
2284
- const p1 = points[1];
2285
- if (!p0 || !p1) return [];
2286
- const mx = (p0.x + p1.x) / 2;
2287
- const my = (p0.y + p1.y) / 2;
2288
- return [{ start: p0, cp1: { x: mx, y: my }, cp2: { x: mx, y: my }, end: p1 }];
2289
- }
2290
- const segments = [];
2291
- const n = points.length;
2292
- for (let i = 0; i < n - 1; i++) {
2293
- const p0 = points[Math.max(0, i - 1)];
2294
- const p1 = points[i];
2295
- const p2 = points[i + 1];
2296
- const p3 = points[Math.min(n - 1, i + 2)];
2297
- if (!p0 || !p1 || !p2 || !p3) continue;
2298
- const cp1 = {
2299
- x: p1.x + (p2.x - p0.x) / 6,
2300
- y: p1.y + (p2.y - p0.y) / 6
2301
- };
2302
- const cp2 = {
2303
- x: p2.x - (p3.x - p1.x) / 6,
2304
- y: p2.y - (p3.y - p1.y) / 6
2305
- };
2306
- segments.push({ start: p1, cp1, cp2, end: p2 });
2307
- }
2308
- return segments;
2309
- }
2310
-
2311
- // src/elements/stroke-cache.ts
2312
- var cache = /* @__PURE__ */ new WeakMap();
2313
- function computeStrokeSegments(stroke) {
2314
- const segments = smoothToSegments(stroke.points);
2315
- const widths = [];
2316
- for (const seg of segments) {
2317
- const w = (pressureToWidth(seg.start.pressure, stroke.width) + pressureToWidth(seg.end.pressure, stroke.width)) / 2;
2318
- widths.push(w);
2319
- }
2320
- const data = { segments, widths };
2321
- cache.set(stroke, data);
2322
- return data;
2323
- }
2324
- function getStrokeRenderData(stroke) {
2325
- const cached = cache.get(stroke);
2326
- if (cached) return cached;
2327
- return computeStrokeSegments(stroke);
2328
- }
2329
-
2330
2386
  // src/elements/grid-renderer.ts
2331
2387
  function getSquareGridLines(bounds, cellSize) {
2332
2388
  if (cellSize <= 0) return { verticals: [], horizontals: [] };
@@ -2719,6 +2775,7 @@ var ElementRenderer = class {
2719
2775
  store = null;
2720
2776
  imageCache = /* @__PURE__ */ new Map();
2721
2777
  onImageLoad = null;
2778
+ onImageError = null;
2722
2779
  camera = null;
2723
2780
  canvasSize = null;
2724
2781
  hexTileCache = null;
@@ -2729,6 +2786,9 @@ var ElementRenderer = class {
2729
2786
  setOnImageLoad(callback) {
2730
2787
  this.onImageLoad = callback;
2731
2788
  }
2789
+ setOnImageError(callback) {
2790
+ this.onImageError = callback;
2791
+ }
2732
2792
  setCamera(camera) {
2733
2793
  this.camera = camera;
2734
2794
  }
@@ -2768,21 +2828,29 @@ var ElementRenderer = class {
2768
2828
  ctx.lineCap = "round";
2769
2829
  ctx.lineJoin = "round";
2770
2830
  ctx.globalAlpha = stroke.opacity;
2771
- const { segments, widths } = getStrokeRenderData(stroke);
2772
- for (let i = 0; i < segments.length; i++) {
2773
- const seg = segments[i];
2774
- const w = widths[i];
2775
- if (!seg || w === void 0) continue;
2776
- ctx.lineWidth = w;
2777
- ctx.beginPath();
2778
- ctx.moveTo(seg.start.x, seg.start.y);
2779
- ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
2780
- ctx.stroke();
2831
+ const data = getStrokeRenderData(stroke);
2832
+ if (data.buckets) {
2833
+ for (const bucket of data.buckets) {
2834
+ ctx.lineWidth = bucket.width;
2835
+ ctx.stroke(bucket.path);
2836
+ }
2837
+ } else {
2838
+ for (let i = 0; i < data.segments.length; i++) {
2839
+ const seg = data.segments[i];
2840
+ const w = data.widths[i];
2841
+ if (!seg || w === void 0) continue;
2842
+ ctx.lineWidth = w;
2843
+ ctx.beginPath();
2844
+ ctx.moveTo(seg.start.x, seg.start.y);
2845
+ ctx.bezierCurveTo(seg.cp1.x, seg.cp1.y, seg.cp2.x, seg.cp2.y, seg.end.x, seg.end.y);
2846
+ ctx.stroke();
2847
+ }
2781
2848
  }
2782
2849
  ctx.restore();
2783
2850
  }
2784
2851
  renderArrow(ctx, arrow) {
2785
- const { visualFrom, visualTo } = this.getVisualEndpoints(arrow);
2852
+ const geometry = getArrowRenderGeometry(arrow);
2853
+ const { visualFrom, visualTo } = this.getVisualEndpoints(arrow, geometry);
2786
2854
  ctx.save();
2787
2855
  ctx.strokeStyle = arrow.color;
2788
2856
  ctx.lineWidth = arrow.width;
@@ -2793,17 +2861,18 @@ var ElementRenderer = class {
2793
2861
  ctx.beginPath();
2794
2862
  ctx.moveTo(visualFrom.x, visualFrom.y);
2795
2863
  if (arrow.bend !== 0) {
2796
- const cp = arrow.cachedControlPoint ?? getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
2797
- ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
2864
+ const cp = geometry.controlPoint;
2865
+ if (cp) {
2866
+ ctx.quadraticCurveTo(cp.x, cp.y, visualTo.x, visualTo.y);
2867
+ }
2798
2868
  } else {
2799
2869
  ctx.lineTo(visualTo.x, visualTo.y);
2800
2870
  }
2801
2871
  ctx.stroke();
2802
- this.renderArrowhead(ctx, arrow, visualTo);
2872
+ this.renderArrowhead(ctx, arrow, visualTo, geometry.tangentEnd);
2803
2873
  ctx.restore();
2804
2874
  }
2805
- renderArrowhead(ctx, arrow, tip) {
2806
- const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
2875
+ renderArrowhead(ctx, arrow, tip, angle) {
2807
2876
  ctx.beginPath();
2808
2877
  ctx.moveTo(tip.x, tip.y);
2809
2878
  ctx.lineTo(
@@ -2818,7 +2887,7 @@ var ElementRenderer = class {
2818
2887
  ctx.fillStyle = arrow.color;
2819
2888
  ctx.fill();
2820
2889
  }
2821
- getVisualEndpoints(arrow) {
2890
+ getVisualEndpoints(arrow, geometry) {
2822
2891
  let visualFrom = arrow.from;
2823
2892
  let visualTo = arrow.to;
2824
2893
  if (!this.store) return { visualFrom, visualTo };
@@ -2827,7 +2896,7 @@ var ElementRenderer = class {
2827
2896
  if (el) {
2828
2897
  const bounds = getElementBounds(el);
2829
2898
  if (bounds) {
2830
- const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 0);
2899
+ const tangentAngle = geometry.tangentStart;
2831
2900
  const rayTarget = {
2832
2901
  x: arrow.from.x + Math.cos(tangentAngle) * 1e3,
2833
2902
  y: arrow.from.y + Math.sin(tangentAngle) * 1e3
@@ -2841,7 +2910,7 @@ var ElementRenderer = class {
2841
2910
  if (el) {
2842
2911
  const bounds = getElementBounds(el);
2843
2912
  if (bounds) {
2844
- const tangentAngle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
2913
+ const tangentAngle = geometry.tangentEnd;
2845
2914
  const rayTarget = {
2846
2915
  x: arrow.to.x - Math.cos(tangentAngle) * 1e3,
2847
2916
  y: arrow.to.y - Math.sin(tangentAngle) * 1e3
@@ -3087,6 +3156,10 @@ var ElementRenderer = class {
3087
3156
  ctx.restore();
3088
3157
  }
3089
3158
  renderImage(ctx, image) {
3159
+ if (this.imageCache.get(image.src) === "failed") {
3160
+ this.renderImagePlaceholder(ctx, image);
3161
+ return;
3162
+ }
3090
3163
  const img = this.getImage(image.src);
3091
3164
  if (!img) return;
3092
3165
  ctx.drawImage(
@@ -3097,6 +3170,27 @@ var ElementRenderer = class {
3097
3170
  image.size.h
3098
3171
  );
3099
3172
  }
3173
+ renderImagePlaceholder(ctx, image) {
3174
+ const { x, y } = image.position;
3175
+ const { w, h } = image.size;
3176
+ ctx.save();
3177
+ ctx.fillStyle = "#eeeeee";
3178
+ ctx.fillRect(x, y, w, h);
3179
+ ctx.strokeStyle = "#bdbdbd";
3180
+ ctx.lineWidth = 1;
3181
+ ctx.strokeRect(x, y, w, h);
3182
+ const glyph = Math.min(24, w / 2, h / 2);
3183
+ const cx = x + w / 2;
3184
+ const cy = y + h / 2;
3185
+ ctx.strokeStyle = "#9e9e9e";
3186
+ ctx.lineWidth = 2;
3187
+ ctx.beginPath();
3188
+ ctx.arc(cx, cy, glyph / 2, 0, Math.PI * 2);
3189
+ ctx.moveTo(cx - glyph / 2, cy + glyph / 2);
3190
+ ctx.lineTo(cx + glyph / 2, cy - glyph / 2);
3191
+ ctx.stroke();
3192
+ ctx.restore();
3193
+ }
3100
3194
  getHexTile(cellSize, orientation, strokeColor, strokeWidth, opacity, scale) {
3101
3195
  const key = `${cellSize}:${orientation}:${strokeColor}:${strokeWidth}:${opacity}:${scale}`;
3102
3196
  if (this.hexTileCacheKey === key && this.hexTileCache) {
@@ -3112,6 +3206,7 @@ var ElementRenderer = class {
3112
3206
  getImage(src) {
3113
3207
  const cached = this.imageCache.get(src);
3114
3208
  if (cached) {
3209
+ if (cached === "failed") return null;
3115
3210
  if (cached instanceof HTMLImageElement) return cached.complete ? cached : null;
3116
3211
  return cached;
3117
3212
  }
@@ -3128,6 +3223,11 @@ var ElementRenderer = class {
3128
3223
  });
3129
3224
  }
3130
3225
  };
3226
+ img.onerror = () => {
3227
+ this.imageCache.set(src, "failed");
3228
+ this.onImageError?.(src);
3229
+ this.onImageLoad?.();
3230
+ };
3131
3231
  return null;
3132
3232
  }
3133
3233
  };
@@ -4174,19 +4274,19 @@ function loadImages(elements) {
4174
4274
  const imageElements = elements.filter(
4175
4275
  (el) => el.type === "image" && "src" in el
4176
4276
  );
4177
- const cache2 = /* @__PURE__ */ new Map();
4178
- if (imageElements.length === 0) return Promise.resolve(cache2);
4277
+ const cache3 = /* @__PURE__ */ new Map();
4278
+ if (imageElements.length === 0) return Promise.resolve(cache3);
4179
4279
  return new Promise((resolve) => {
4180
4280
  let remaining = imageElements.length;
4181
4281
  const done = () => {
4182
4282
  remaining--;
4183
- if (remaining <= 0) resolve(cache2);
4283
+ if (remaining <= 0) resolve(cache3);
4184
4284
  };
4185
4285
  for (const el of imageElements) {
4186
4286
  const img = new Image();
4187
4287
  img.crossOrigin = "anonymous";
4188
4288
  img.onload = () => {
4189
- cache2.set(el.id, img);
4289
+ cache3.set(el.id, img);
4190
4290
  done();
4191
4291
  };
4192
4292
  img.onerror = done;
@@ -4667,18 +4767,39 @@ var RenderStats = class {
4667
4767
  frameTimes = [];
4668
4768
  frameCount = 0;
4669
4769
  _lastGridMs = 0;
4670
- recordFrame(durationMs, gridMs) {
4770
+ _lastLayersMs = 0;
4771
+ _lastBackgroundMs = 0;
4772
+ _lastCompositeMs = 0;
4773
+ _lastOverlayMs = 0;
4774
+ recordFrame(durationMs, breakdown) {
4671
4775
  this.frameCount++;
4672
4776
  this.frameTimes.push(durationMs);
4673
4777
  if (this.frameTimes.length > SAMPLE_SIZE) {
4674
4778
  this.frameTimes.shift();
4675
4779
  }
4676
- if (gridMs !== void 0) this._lastGridMs = gridMs;
4780
+ if (breakdown !== void 0) {
4781
+ if (breakdown.gridMs !== void 0) this._lastGridMs = breakdown.gridMs;
4782
+ if (breakdown.layersMs !== void 0) this._lastLayersMs = breakdown.layersMs;
4783
+ if (breakdown.backgroundMs !== void 0) this._lastBackgroundMs = breakdown.backgroundMs;
4784
+ if (breakdown.compositeMs !== void 0) this._lastCompositeMs = breakdown.compositeMs;
4785
+ if (breakdown.overlayMs !== void 0) this._lastOverlayMs = breakdown.overlayMs;
4786
+ }
4677
4787
  }
4678
4788
  getSnapshot() {
4679
4789
  const times = this.frameTimes;
4680
4790
  if (times.length === 0) {
4681
- return { fps: 0, avgFrameMs: 0, p95FrameMs: 0, lastFrameMs: 0, lastGridMs: 0, frameCount: 0 };
4791
+ return {
4792
+ fps: 0,
4793
+ avgFrameMs: 0,
4794
+ p95FrameMs: 0,
4795
+ lastFrameMs: 0,
4796
+ lastGridMs: 0,
4797
+ layersMs: 0,
4798
+ backgroundMs: 0,
4799
+ compositeMs: 0,
4800
+ overlayMs: 0,
4801
+ frameCount: 0
4802
+ };
4682
4803
  }
4683
4804
  const avg = times.reduce((a, b) => a + b, 0) / times.length;
4684
4805
  const sorted = [...times].sort((a, b) => a - b);
@@ -4690,6 +4811,10 @@ var RenderStats = class {
4690
4811
  p95FrameMs: Math.round((sorted[p95Index] ?? 0) * 100) / 100,
4691
4812
  lastFrameMs: Math.round(lastFrame * 100) / 100,
4692
4813
  lastGridMs: Math.round(this._lastGridMs * 100) / 100,
4814
+ layersMs: Math.round(this._lastLayersMs * 100) / 100,
4815
+ backgroundMs: Math.round(this._lastBackgroundMs * 100) / 100,
4816
+ compositeMs: Math.round(this._lastCompositeMs * 100) / 100,
4817
+ overlayMs: Math.round(this._lastOverlayMs * 100) / 100,
4693
4818
  frameCount: this.frameCount
4694
4819
  };
4695
4820
  }
@@ -4717,7 +4842,7 @@ var RenderLoop = class {
4717
4842
  lastCamX;
4718
4843
  lastCamY;
4719
4844
  stats = new RenderStats();
4720
- lastGridMs = 0;
4845
+ layerGroups = /* @__PURE__ */ new Map();
4721
4846
  gridCacheCanvas = null;
4722
4847
  gridCacheCtx = null;
4723
4848
  gridCacheZoom = -1;
@@ -4815,6 +4940,9 @@ var RenderLoop = class {
4815
4940
  const t0 = performance.now();
4816
4941
  const ctx = this.canvasEl.getContext("2d");
4817
4942
  if (!ctx) return;
4943
+ let layersMs = 0;
4944
+ let compositeMs = 0;
4945
+ let gridMs = 0;
4818
4946
  const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
4819
4947
  const cssWidth = this.canvasEl.clientWidth;
4820
4948
  const cssHeight = this.canvasEl.clientHeight;
@@ -4831,6 +4959,7 @@ var RenderLoop = class {
4831
4959
  ctx.scale(dpr, dpr);
4832
4960
  this.renderer.setCanvasSize(cssWidth, cssHeight);
4833
4961
  const hasGridElement = this.store.getElementsByType("grid").length > 0;
4962
+ const bgT0 = performance.now();
4834
4963
  if (hasGridElement) {
4835
4964
  ctx.save();
4836
4965
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -4839,6 +4968,7 @@ var RenderLoop = class {
4839
4968
  } else {
4840
4969
  this.background.render(ctx, this.camera);
4841
4970
  }
4971
+ const backgroundMs = performance.now() - bgT0;
4842
4972
  ctx.save();
4843
4973
  ctx.translate(this.camera.position.x, this.camera.position.y);
4844
4974
  ctx.scale(this.camera.zoom, this.camera.zoom);
@@ -4851,7 +4981,7 @@ var RenderLoop = class {
4851
4981
  h: visibleRect.h + margin * 2
4852
4982
  };
4853
4983
  const allElements = this.store.getAll();
4854
- const layerElements = /* @__PURE__ */ new Map();
4984
+ this.layerGroups.clear();
4855
4985
  const gridElements = [];
4856
4986
  let domZIndex = 0;
4857
4987
  for (const element of allElements) {
@@ -4874,25 +5004,30 @@ var RenderLoop = class {
4874
5004
  gridElements.push(element);
4875
5005
  continue;
4876
5006
  }
4877
- let group = layerElements.get(element.layerId);
5007
+ let group = this.layerGroups.get(element.layerId);
4878
5008
  if (!group) {
4879
5009
  group = [];
4880
- layerElements.set(element.layerId, group);
5010
+ this.layerGroups.set(element.layerId, group);
4881
5011
  }
4882
5012
  group.push(element);
4883
5013
  }
4884
- for (const [layerId, elements] of layerElements) {
5014
+ for (const [layerId, elements] of this.layerGroups) {
4885
5015
  const isActiveDrawingLayer = layerId === this.activeDrawingLayerId;
4886
5016
  if (!this.layerCache.isDirty(layerId)) {
5017
+ const compT0 = performance.now();
4887
5018
  this.compositeLayerCache(ctx, layerId, dpr);
5019
+ compositeMs += performance.now() - compT0;
4888
5020
  continue;
4889
5021
  }
4890
5022
  if (isActiveDrawingLayer) {
5023
+ const compT0 = performance.now();
4891
5024
  this.compositeLayerCache(ctx, layerId, dpr);
5025
+ compositeMs += performance.now() - compT0;
4892
5026
  continue;
4893
5027
  }
4894
5028
  const offCtx = this.layerCache.getContext(layerId);
4895
5029
  if (offCtx) {
5030
+ const layerT0 = performance.now();
4896
5031
  const offCanvas = this.layerCache.getCanvas(layerId);
4897
5032
  offCtx.clearRect(0, 0, offCanvas.width, offCanvas.height);
4898
5033
  offCtx.save();
@@ -4906,7 +5041,10 @@ var RenderLoop = class {
4906
5041
  }
4907
5042
  offCtx.restore();
4908
5043
  this.layerCache.markClean(layerId);
5044
+ layersMs += performance.now() - layerT0;
5045
+ const compT0 = performance.now();
4909
5046
  this.compositeLayerCache(ctx, layerId, dpr);
5047
+ compositeMs += performance.now() - compT0;
4910
5048
  }
4911
5049
  }
4912
5050
  if (gridElements.length > 0) {
@@ -4947,15 +5085,23 @@ var RenderLoop = class {
4947
5085
  this.gridCacheHeight = cssHeight;
4948
5086
  this.lastGridRef = gridRef;
4949
5087
  }
4950
- this.lastGridMs = performance.now() - gridT0;
5088
+ gridMs = performance.now() - gridT0;
4951
5089
  }
5090
+ const overlayT0 = performance.now();
4952
5091
  const activeTool = this.toolManager.activeTool;
4953
5092
  if (activeTool?.renderOverlay) {
4954
5093
  activeTool.renderOverlay(ctx);
4955
5094
  }
5095
+ const overlayMs = performance.now() - overlayT0;
4956
5096
  ctx.restore();
4957
5097
  ctx.restore();
4958
- this.stats.recordFrame(performance.now() - t0, this.lastGridMs);
5098
+ this.stats.recordFrame(performance.now() - t0, {
5099
+ gridMs,
5100
+ layersMs,
5101
+ backgroundMs,
5102
+ compositeMs,
5103
+ overlayMs
5104
+ });
4959
5105
  }
4960
5106
  };
4961
5107
 
@@ -5039,6 +5185,17 @@ var Viewport = class {
5039
5185
  this.renderLoop.markAllLayersDirty();
5040
5186
  this.requestRender();
5041
5187
  });
5188
+ this.renderer.setOnImageError((src) => {
5189
+ const elementIds = [];
5190
+ for (const el of this.store.getAll()) {
5191
+ if (el.type === "image" && el.src === src) elementIds.push(el.id);
5192
+ }
5193
+ if (options.onImageError) {
5194
+ options.onImageError({ src, elementIds });
5195
+ } else {
5196
+ console.warn(`[fieldnotes] image failed to load: ${src}`);
5197
+ }
5198
+ });
5042
5199
  this.noteEditor = new NoteEditor({
5043
5200
  fontSizePresets: options.fontSizePresets,
5044
5201
  toolbar: options.toolbar,
@@ -5356,7 +5513,7 @@ var Viewport = class {
5356
5513
  const id = setInterval(() => {
5357
5514
  const s = this.getRenderStats();
5358
5515
  console.log(
5359
- `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms`
5516
+ `[FieldNotes] fps=${s.fps} frame=${s.avgFrameMs}ms p95=${s.p95FrameMs}ms grid=${s.lastGridMs}ms layers=${s.layersMs}ms comp=${s.compositeMs}ms bg=${s.backgroundMs}ms overlay=${s.overlayMs}ms`
5360
5517
  );
5361
5518
  }, intervalMs);
5362
5519
  return () => clearInterval(id);
@@ -5743,6 +5900,43 @@ var PencilTool = class {
5743
5900
  }
5744
5901
  };
5745
5902
 
5903
+ // src/elements/stroke-hit.ts
5904
+ function distSqToSegment(p, a, b) {
5905
+ const abx = b.x - a.x;
5906
+ const aby = b.y - a.y;
5907
+ const apx = p.x - a.x;
5908
+ const apy = p.y - a.y;
5909
+ const lenSq = abx * abx + aby * aby;
5910
+ if (lenSq === 0) {
5911
+ return apx * apx + apy * apy;
5912
+ }
5913
+ const t = Math.max(0, Math.min(1, (apx * abx + apy * aby) / lenSq));
5914
+ const dx = p.x - (a.x + t * abx);
5915
+ const dy = p.y - (a.y + t * aby);
5916
+ return dx * dx + dy * dy;
5917
+ }
5918
+ function hitTestStroke(stroke, point, radius) {
5919
+ const bounds = getElementBounds(stroke);
5920
+ if (!bounds) return false;
5921
+ if (point.x < bounds.x - radius || point.x > bounds.x + bounds.w + radius || point.y < bounds.y - radius || point.y > bounds.y + bounds.h + radius) {
5922
+ return false;
5923
+ }
5924
+ const radiusSq = radius * radius;
5925
+ const local = { x: point.x - stroke.position.x, y: point.y - stroke.position.y };
5926
+ const { segments } = getStrokeRenderData(stroke);
5927
+ if (segments.length === 0) {
5928
+ const p = stroke.points[0];
5929
+ if (!p) return false;
5930
+ const dx = p.x - local.x;
5931
+ const dy = p.y - local.y;
5932
+ return dx * dx + dy * dy <= radiusSq;
5933
+ }
5934
+ for (const seg of segments) {
5935
+ if (distSqToSegment(local, seg.start, seg.end) <= radiusSq) return true;
5936
+ }
5937
+ return false;
5938
+ }
5939
+
5746
5940
  // src/tools/eraser-tool.ts
5747
5941
  var DEFAULT_RADIUS = 20;
5748
5942
  function makeEraserCursor(radius) {
@@ -5801,12 +5995,7 @@ var EraserTool = class {
5801
5995
  if (erased) ctx.requestRender();
5802
5996
  }
5803
5997
  strokeIntersects(stroke, point) {
5804
- const radiusSq = this.radius * this.radius;
5805
- return stroke.points.some((p) => {
5806
- const dx = p.x + stroke.position.x - point.x;
5807
- const dy = p.y + stroke.position.y - point.y;
5808
- return dx * dx + dy * dy <= radiusSq;
5809
- });
5998
+ return hitTestStroke(stroke, point, this.radius);
5810
5999
  }
5811
6000
  };
5812
6001
 
@@ -6486,12 +6675,7 @@ var SelectTool = class {
6486
6675
  return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
6487
6676
  }
6488
6677
  if (el.type === "stroke") {
6489
- const HIT_RADIUS = 10;
6490
- return el.points.some((p) => {
6491
- const dx = p.x + el.position.x - point.x;
6492
- const dy = p.y + el.position.y - point.y;
6493
- return dx * dx + dy * dy <= HIT_RADIUS * HIT_RADIUS;
6494
- });
6678
+ return hitTestStroke(el, point, 10);
6495
6679
  }
6496
6680
  if (el.type === "arrow") {
6497
6681
  return isNearBezier(point, el.from, el.to, el.bend, 10);
@@ -7327,7 +7511,7 @@ var TemplateTool = class {
7327
7511
  };
7328
7512
 
7329
7513
  // src/index.ts
7330
- var VERSION = "0.20.0";
7514
+ var VERSION = "0.22.0";
7331
7515
  // Annotate the CommonJS export names for ESM import in node:
7332
7516
  0 && (module.exports = {
7333
7517
  AddElementCommand,