@vectojs/core 1.20.0 → 1.21.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.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CanvasRenderer,
3
+ GlyphRasterAtlas,
3
4
  SVGRenderer,
4
5
  TextRasterCache,
5
6
  WebGPUParticleSystemManager,
@@ -7,13 +8,13 @@ import {
7
8
  isSafeUrl,
8
9
  parseColorToRGBA,
9
10
  sanitizeUrl
10
- } from "./chunk-QS3CUV7H.mjs";
11
+ } from "./chunk-RTENOAYT.mjs";
11
12
  import {
12
13
  Entity,
13
14
  MSDFTextEntity,
14
15
  SVGEntity,
15
16
  VectoJSEvent
16
- } from "./chunk-TJCXB2F6.mjs";
17
+ } from "./chunk-FRMLD4PP.mjs";
17
18
 
18
19
  // src/tree/Scene.ts
19
20
  import { SpringDriver, TweenDriver } from "@vectojs/animation";
@@ -1687,6 +1688,29 @@ var Scene = class _Scene {
1687
1688
  contentGridCalibrationFrames = /* @__PURE__ */ new Map();
1688
1689
  /** Detached, untransformed font probes used by the cold calibration pass. */
1689
1690
  contentGridCalibrationProbes = /* @__PURE__ */ new Map();
1691
+ /**
1692
+ * Monotonic stamp identifying the conditions grid cells were calibrated under.
1693
+ *
1694
+ * Calibration measures the difference between the advance the canvas grid assigns
1695
+ * a cluster and the width the browser lays it out at, then writes a per-cell
1696
+ * `scaleX`. That result stays valid until the font or the page scale changes, and
1697
+ * it lives on the cell element — so a cell carrying this stamp needs no further
1698
+ * work.
1699
+ *
1700
+ * The scan that feeds calibration was O(cells) on every revision bump: for a
1701
+ * streaming code block it re-derived a measurement key for every cell in the
1702
+ * block each frame in order to produce only ~20 distinct keys, costing about
1703
+ * 2.5 ms/frame after the `style.font` fix and still over half of `a11ySync`. Since
1704
+ * carrier reuse (#244) leaves untouched lines — and therefore their calibrated
1705
+ * transforms — in place, cells stamped with the current generation can simply be
1706
+ * skipped, making the scan O(new cells) instead.
1707
+ *
1708
+ * A plain incrementing integer rather than the descriptive calibration key,
1709
+ * because it goes into an attribute selector and must not need escaping.
1710
+ */
1711
+ contentGridCalibrationGeneration = 0;
1712
+ /** The `(fontEpoch, pageScale)` pair the current generation corresponds to. */
1713
+ contentGridCalibrationStamp = "";
1690
1714
  /** Invalidates grid font calibration after browser font availability changes. */
1691
1715
  contentFontEpoch = 0;
1692
1716
  /** Cached Canvas-to-client scale for the current font/viewport epoch. */
@@ -2795,6 +2819,29 @@ var Scene = class _Scene {
2795
2819
  this.contentSelectionAnchor = null;
2796
2820
  if (this.a11yRoot) this.a11yRoot.style.pointerEvents = "none";
2797
2821
  }
2822
+ /**
2823
+ * Index of the carrier line currently holding a selection inside `el`, or
2824
+ * `null`.
2825
+ *
2826
+ * Lets a partial re-materialization decide whether the user's selection is even
2827
+ * affected. Checks the tracked anchor first (it survives a drag) and falls back
2828
+ * to the live DOM selection.
2829
+ */
2830
+ contentGridSelectionLine(el) {
2831
+ const candidates = [this.contentSelectionAnchor?.node];
2832
+ if (typeof window !== "undefined" && typeof window.getSelection === "function") {
2833
+ const selection = window.getSelection();
2834
+ candidates.push(selection?.anchorNode, selection?.focusNode);
2835
+ }
2836
+ for (const candidate of candidates) {
2837
+ if (!candidate || !el.contains(candidate)) continue;
2838
+ let cursor = candidate;
2839
+ while (cursor && cursor.parentNode !== el) cursor = cursor.parentNode;
2840
+ const lineIndex = cursor?.dataset?.vectoGridLine;
2841
+ if (lineIndex !== void 0) return Number(lineIndex);
2842
+ }
2843
+ return null;
2844
+ }
2798
2845
  releaseContentSelectionForRebuild(el) {
2799
2846
  const selection = typeof window !== "undefined" && typeof window.getSelection === "function" ? window.getSelection() : null;
2800
2847
  const ownsSelection = this.contentSelectionAnchor && el.contains(this.contentSelectionAnchor.node) || (selection?.anchorNode ? el.contains(selection.anchorNode) : false) || (selection?.focusNode ? el.contains(selection.focusNode) : false);
@@ -2880,7 +2927,18 @@ var Scene = class _Scene {
2880
2927
  this.registerActiveDriverSubtree(entity);
2881
2928
  return this;
2882
2929
  }
2883
- clearContentGridState(entityId, el) {
2930
+ /**
2931
+ * Reset per-grid calibration and bookkeeping before a (re)materialization.
2932
+ *
2933
+ * @param entityId - Owning entity, keyed into the calibration maps.
2934
+ * @param el - The projection element.
2935
+ * @param releaseSelection - Whether to drop a selection this element owns.
2936
+ * Pass `false` when carrier lines are being reused: the selection's DOM nodes
2937
+ * survive the pass, so tearing it down would wipe a user's selection on every
2938
+ * streamed chunk — the exact bug `preserveContentSelectionAcrossRebuild`
2939
+ * exists to prevent on the non-grid path.
2940
+ */
2941
+ clearContentGridState(entityId, el, releaseSelection = true) {
2884
2942
  const calibrationFrame = this.contentGridCalibrationFrames.get(entityId);
2885
2943
  if (calibrationFrame !== void 0 && typeof cancelAnimationFrame === "function") {
2886
2944
  cancelAnimationFrame(calibrationFrame);
@@ -2896,7 +2954,7 @@ var Scene = class _Scene {
2896
2954
  delete el.dataset.vectoGridMaterializeMs;
2897
2955
  delete el.dataset.vectoGridCalibrationSamples;
2898
2956
  delete el.dataset.vectoGridCalibrationMs;
2899
- this.releaseContentSelectionForRebuild(el);
2957
+ if (releaseSelection) this.releaseContentSelectionForRebuild(el);
2900
2958
  }
2901
2959
  /**
2902
2960
  * Drop any projected elements under `node` without touching the entity tree.
@@ -3226,6 +3284,26 @@ var Scene = class _Scene {
3226
3284
  get overlayRootEntity() {
3227
3285
  return this.overlayRoot;
3228
3286
  }
3287
+ /**
3288
+ * Advance and render exactly one frame, synchronously.
3289
+ *
3290
+ * This renders UNCONDITIONALLY: it consults neither {@link renderMode} nor
3291
+ * {@link dirty}, and it does not apply the `always`-mode idle auto-throttle.
3292
+ * That is deliberate — a deterministic driver (video export, a test, a
3293
+ * fixed-step benchmark) asks for a frame because it wants that frame, not a
3294
+ * scheduler opinion about whether it is needed.
3295
+ *
3296
+ * The consequence is a measurement footgun worth stating explicitly: a
3297
+ * benchmark that drives frames through `step()` CANNOT observe frame skipping,
3298
+ * so `always` and `onDemand` produce byte-identical draw counts through this
3299
+ * path. An investigation into whether `onDemand` skips redundant repaints once
3300
+ * concluded "it does not" on exactly that basis; on the real rAF loop the same
3301
+ * workload rendered ~1.0 frames per content change. To measure anything about
3302
+ * scheduling, use {@link start} and let `requestAnimationFrame` drive.
3303
+ *
3304
+ * @param dt Seconds to advance. Not clamped by `MAX_FRAME_DT` — the caller
3305
+ * chooses the step, since determinism is the point.
3306
+ */
3229
3307
  step(dt) {
3230
3308
  const time = this.lastTime + dt;
3231
3309
  this.lastTime = time;
@@ -3244,6 +3322,24 @@ var Scene = class _Scene {
3244
3322
  this.dirty = true;
3245
3323
  if (this._dirtyTracking && source) this.recordDirtyReason(source);
3246
3324
  }
3325
+ /**
3326
+ * Increments whenever the tree's shape changes: add, remove or reparent.
3327
+ *
3328
+ * Already maintained for the resident WASM transform store (see
3329
+ * {@link markStructureChanged}, called from `Entity.add`/`remove`), and exposed
3330
+ * here because a cache of the tree's shape — a DevTools tree model, a serialized
3331
+ * snapshot — is valid exactly as long as this value is unchanged. Comparing it is
3332
+ * O(1) against re-walking the tree, which is what it replaces: DevTools rebuilt
3333
+ * both trees on a fixed 500 ms interval, a constant cost proportional to entity
3334
+ * count, purely because it had no way to ask whether the shape had changed.
3335
+ *
3336
+ * Property changes do NOT bump it. Moving or restyling an entity leaves the
3337
+ * shape intact, so a consumer that also cares about values must read those
3338
+ * directly rather than rebuilding a tree.
3339
+ */
3340
+ get structureVersion() {
3341
+ return this._structureVersion;
3342
+ }
3247
3343
  /**
3248
3344
  * Record who marked the scene dirty and why.
3249
3345
  *
@@ -3370,6 +3466,7 @@ var Scene = class _Scene {
3370
3466
  this.pruneA11ySubtree(node);
3371
3467
  return;
3372
3468
  }
3469
+ const nodeStart = this._phaseTiming ? performance.now() : 0;
3373
3470
  if (this.shouldProjectA11y(node)) {
3374
3471
  let el = this.a11yElements.get(node.id);
3375
3472
  const attrs = node.getA11yAttributes();
@@ -3712,7 +3809,14 @@ var Scene = class _Scene {
3712
3809
  if (el.style.display !== display) el.style.display = display;
3713
3810
  }
3714
3811
  }
3715
- this.syncContentProjection(node);
3812
+ if (this._phaseTiming) {
3813
+ const projectionStart = performance.now();
3814
+ this.syncContentProjection(node);
3815
+ this._recordPhase("contentProjection", performance.now() - projectionStart);
3816
+ this._recordPhase("a11yNodes", projectionStart - nodeStart);
3817
+ } else {
3818
+ this.syncContentProjection(node);
3819
+ }
3716
3820
  for (const child of node.children) this.syncA11y(child);
3717
3821
  if (node === this.root) {
3718
3822
  for (const overlay of this.overlayRoot.children) this.syncA11y(overlay);
@@ -3826,7 +3930,9 @@ var Scene = class _Scene {
3826
3930
  this.clearContentGridState(node.id, el);
3827
3931
  }
3828
3932
  if (projection.grid) {
3933
+ const gridSyncStart = this._phaseTiming ? performance.now() : 0;
3829
3934
  this.syncContentGridProjection(node, el, projection, projection.grid);
3935
+ if (this._phaseTiming) this._recordPhase("gridSync", performance.now() - gridSyncStart);
3830
3936
  } else if (lines && lines.length > 0) {
3831
3937
  const signature = JSON.stringify({
3832
3938
  lines,
@@ -3932,16 +4038,33 @@ var Scene = class _Scene {
3932
4038
  const signature = `${grid.revision}`;
3933
4039
  if (el.dataset.vectoContentGrid !== signature) {
3934
4040
  const materializeStart = typeof performance !== "undefined" ? performance.now() : 0;
3935
- this.clearContentGridState(node.id, el);
3936
- el.replaceChildren();
4041
+ this.clearContentGridState(node.id, el, false);
3937
4042
  const projectionLines = projection.lines ?? [];
4043
+ const selectionLine = this.contentGridSelectionLine(el);
4044
+ let rebuiltSelectionLine = false;
4045
+ const existingLines = el.children;
3938
4046
  for (let lineIndex = 0; lineIndex < grid.lines.length; lineIndex++) {
3939
4047
  const gridLine = grid.lines[lineIndex];
3940
4048
  const projectedLine = projectionLines[lineIndex];
3941
4049
  const lineHeight = projectedLine?.lineHeight ?? grid.lineHeight;
3942
4050
  const baseline = projectedLine?.baseline ?? grid.baseline;
3943
4051
  const lineFont = projectedLine?.font ?? grid.font;
4052
+ const lineSignature = contentGridLineSignature(
4053
+ grid,
4054
+ gridLine,
4055
+ projectedLine,
4056
+ lineHeight,
4057
+ baseline,
4058
+ lineFont,
4059
+ lineIndex === 0
4060
+ );
4061
+ const reusable = existingLines[lineIndex];
4062
+ if (reusable !== void 0 && reusable.dataset.vectoGridLineSig === lineSignature && reusable.dataset.vectoGridLine === `${lineIndex}`) {
4063
+ continue;
4064
+ }
4065
+ if (selectionLine !== null && selectionLine === lineIndex) rebuiltSelectionLine = true;
3944
4066
  const lineElement = document.createElement("span");
4067
+ lineElement.dataset.vectoGridLineSig = lineSignature;
3945
4068
  lineElement.dir = "ltr";
3946
4069
  lineElement.dataset.vectoGridLine = `${lineIndex}`;
3947
4070
  lineElement.style.position = "absolute";
@@ -3983,6 +4106,8 @@ var Scene = class _Scene {
3983
4106
  cellElement.style.font = lineFont;
3984
4107
  cellElement.style.lineHeight = `${lineHeight}px`;
3985
4108
  cellElement.style.transformOrigin = "0 50%";
4109
+ cellElement.dataset.vectoGridFont = lineFont;
4110
+ cellElement.dataset.vectoGridLineHeight = `${lineHeight}px`;
3986
4111
  lineElement.appendChild(cellElement);
3987
4112
  logicalX += cell.advance;
3988
4113
  }
@@ -4006,13 +4131,24 @@ var Scene = class _Scene {
4006
4131
  lineElement.appendChild(marker);
4007
4132
  }
4008
4133
  }
4009
- el.appendChild(lineElement);
4134
+ const occupant = el.children[lineIndex];
4135
+ if (occupant) el.replaceChild(lineElement, occupant);
4136
+ else el.appendChild(lineElement);
4010
4137
  }
4138
+ while (el.children.length > grid.lines.length) {
4139
+ if (selectionLine !== null && selectionLine >= grid.lines.length) {
4140
+ rebuiltSelectionLine = true;
4141
+ }
4142
+ el.lastElementChild?.remove();
4143
+ }
4144
+ if (rebuiltSelectionLine) this.releaseContentSelectionForRebuild(el);
4011
4145
  el.dataset.vectoProjectionLines = signature;
4012
4146
  el.dataset.vectoContentGrid = signature;
4013
4147
  el.dataset.vectoGridCarriers = `${el.querySelectorAll("[data-vecto-grid-cell]").length}`;
4014
4148
  if (typeof performance !== "undefined") {
4015
- el.dataset.vectoGridMaterializeMs = `${performance.now() - materializeStart}`;
4149
+ const materializeMs = performance.now() - materializeStart;
4150
+ el.dataset.vectoGridMaterializeMs = `${materializeMs}`;
4151
+ if (this._phaseTiming) this._recordPhase("gridMaterialize", materializeMs);
4016
4152
  }
4017
4153
  delete el.dataset.vectoGridCalibration;
4018
4154
  delete el.dataset.vectoGridReady;
@@ -4020,7 +4156,11 @@ var Scene = class _Scene {
4020
4156
  const pageScaleX = this.getContentMetricScaleX();
4021
4157
  const calibrationKey = `${signature}:${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
4022
4158
  if (el.dataset.vectoGridCalibration !== calibrationKey) {
4159
+ const calibStart = this._phaseTiming ? performance.now() : 0;
4023
4160
  this.scheduleContentGridCalibration(node.id, el, calibrationKey, pageScaleX);
4161
+ if (this._phaseTiming) {
4162
+ this._recordPhase("gridCalibrateSchedule", performance.now() - calibStart);
4163
+ }
4024
4164
  }
4025
4165
  }
4026
4166
  getContentMetricScaleX() {
@@ -4038,6 +4178,27 @@ var Scene = class _Scene {
4038
4178
  scheduleContentGridCalibration(entityId, el, calibrationKey, pageScaleX) {
4039
4179
  if (typeof requestAnimationFrame !== "function") return;
4040
4180
  if (el.dataset.vectoGridCalibrationPending === calibrationKey) return;
4181
+ const stamp = `${this.contentFontEpoch}:${pageScaleX.toFixed(4)}`;
4182
+ if (this.contentGridCalibrationStamp !== stamp) {
4183
+ this.contentGridCalibrationStamp = stamp;
4184
+ this.contentGridCalibrationGeneration++;
4185
+ }
4186
+ const generation = `${this.contentGridCalibrationGeneration}`;
4187
+ const pendingCells = el.querySelectorAll(
4188
+ `[data-vecto-grid-cell]:not([data-vecto-grid-calib="${generation}"])`
4189
+ );
4190
+ if (pendingCells.length === 0) {
4191
+ el.dataset.vectoGridCalibrationSamples = "0";
4192
+ delete el.dataset.vectoGridCalibrationPending;
4193
+ const readyFrame = requestAnimationFrame(() => {
4194
+ this.contentGridCalibrationFrames.delete(entityId);
4195
+ if (!el.isConnected) return;
4196
+ el.dataset.vectoGridCalibration = calibrationKey;
4197
+ el.dataset.vectoGridReady = "true";
4198
+ });
4199
+ this.contentGridCalibrationFrames.set(entityId, readyFrame);
4200
+ return;
4201
+ }
4041
4202
  const previous = this.contentGridCalibrationFrames.get(entityId);
4042
4203
  if (previous !== void 0 && typeof cancelAnimationFrame === "function") {
4043
4204
  cancelAnimationFrame(previous);
@@ -4068,18 +4229,22 @@ var Scene = class _Scene {
4068
4229
  probe.append(probeOrigin, probeX);
4069
4230
  const measurements = [];
4070
4231
  const measurementsByKey = /* @__PURE__ */ new Map();
4071
- for (const target of el.querySelectorAll("[data-vecto-grid-cell]")) {
4232
+ const scanStart = this._phaseTiming ? performance.now() : 0;
4233
+ for (const target of pendingCells) {
4072
4234
  const sourceLength = Number(target.dataset.vectoGridSourceLength ?? 0);
4073
4235
  const targetWidth = Number(target.dataset.vectoGridAdvance ?? 0);
4074
- if (sourceLength <= 0 || targetWidth <= 0) continue;
4236
+ if (sourceLength <= 0 || targetWidth <= 0) {
4237
+ target.dataset.vectoGridCalib = generation;
4238
+ continue;
4239
+ }
4075
4240
  const sourceText = target.textContent?.slice(0, sourceLength) ?? "";
4076
- if (!sourceText) continue;
4077
- const measurementKey = JSON.stringify([
4078
- target.style.font,
4079
- target.style.lineHeight,
4080
- targetWidth,
4081
- sourceText
4082
- ]);
4241
+ if (!sourceText) {
4242
+ target.dataset.vectoGridCalib = generation;
4243
+ continue;
4244
+ }
4245
+ const cellFont = target.dataset.vectoGridFont ?? "";
4246
+ const cellLineHeight = target.dataset.vectoGridLineHeight ?? "";
4247
+ const measurementKey = JSON.stringify([cellFont, cellLineHeight, targetWidth, sourceText]);
4083
4248
  const shared = measurementsByKey.get(measurementKey);
4084
4249
  if (shared) {
4085
4250
  shared.targets.push(target);
@@ -4091,8 +4256,8 @@ var Scene = class _Scene {
4091
4256
  carrier.style.left = "0";
4092
4257
  carrier.style.top = "0";
4093
4258
  carrier.style.whiteSpace = "pre";
4094
- carrier.style.font = target.style.font;
4095
- carrier.style.lineHeight = target.style.lineHeight;
4259
+ carrier.style.font = cellFont;
4260
+ carrier.style.lineHeight = cellLineHeight;
4096
4261
  carrier.style.fontVariantLigatures = "none";
4097
4262
  carrier.style.fontKerning = "none";
4098
4263
  const source = document.createTextNode(sourceText);
@@ -4107,7 +4272,17 @@ var Scene = class _Scene {
4107
4272
  measurements.push(measurement);
4108
4273
  measurementsByKey.set(measurementKey, measurement);
4109
4274
  }
4275
+ if (this._phaseTiming) this._recordPhase("calibScan", performance.now() - scanStart);
4276
+ if (measurements.length === 0) {
4277
+ el.dataset.vectoGridCalibration = calibrationKey;
4278
+ el.dataset.vectoGridReady = "true";
4279
+ el.dataset.vectoGridCalibrationSamples = "0";
4280
+ delete el.dataset.vectoGridCalibrationPending;
4281
+ return;
4282
+ }
4283
+ const appendStart = this._phaseTiming ? performance.now() : 0;
4110
4284
  (this.a11yRoot ?? document.body ?? document.documentElement).appendChild(probe);
4285
+ if (this._phaseTiming) this._recordPhase("calibProbeBuild", performance.now() - appendStart);
4111
4286
  el.dataset.vectoGridCalibrationSamples = `${measurements.length}`;
4112
4287
  this.contentGridCalibrationProbes.set(entityId, probe);
4113
4288
  el.dataset.vectoGridCalibrationPending = calibrationKey;
@@ -4151,6 +4326,7 @@ var Scene = class _Scene {
4151
4326
  }
4152
4327
  for (const { element, scale } of updates) {
4153
4328
  element.style.transform = Math.abs(scale - 1) <= 1e-3 ? "" : `scaleX(${scale})`;
4329
+ element.dataset.vectoGridCalib = generation;
4154
4330
  }
4155
4331
  el.dataset.vectoGridCalibration = calibrationKey;
4156
4332
  el.dataset.vectoGridReady = "true";
@@ -5091,6 +5267,41 @@ function intersectBounds(a, b) {
5091
5267
  function pointInBounds(b, x, y) {
5092
5268
  return x >= b.x && x <= b.x + b.width && y >= b.y && y <= b.y + b.height;
5093
5269
  }
5270
+ function contentGridLineSignature(grid, line, projected, lineHeight, baseline, font, isFirstLine) {
5271
+ const parts = [
5272
+ // Line box: position, size, and the font that resolves its baseline.
5273
+ `${projected?.x ?? 0}`,
5274
+ `${projected?.y ?? ""}`,
5275
+ `${lineHeight}`,
5276
+ `${baseline}`,
5277
+ font,
5278
+ `${line.width}`,
5279
+ // The trailing hard break belongs to this line and lands in the DOM text.
5280
+ grid.source.slice(line.sourceEnd, line.nextSourceStart),
5281
+ // The basis markers are appended only to line 0, so a line moving to or from
5282
+ // index 0 changes its DOM even when nothing else does.
5283
+ isFirstLine ? "1" : "0"
5284
+ ];
5285
+ if (line.cells.length === 0) {
5286
+ parts.push("empty");
5287
+ } else {
5288
+ for (const cell of line.cells) {
5289
+ parts.push(
5290
+ `${cell.sourceStart}`,
5291
+ `${cell.sourceEnd}`,
5292
+ `${cell.x}`,
5293
+ `${cell.advance}`,
5294
+ `${cell.level}`,
5295
+ cell.sourceCaretOffsets.join("."),
5296
+ // Source text, not `cell.glyph`: the carrier holds the original characters
5297
+ // (the shaped glyph is the canvas's business), so a change in shaping alone
5298
+ // must not invalidate a carrier, and a change in source must.
5299
+ grid.source.slice(cell.sourceStart, cell.sourceEnd)
5300
+ );
5301
+ }
5302
+ }
5303
+ return parts.join("");
5304
+ }
5094
5305
 
5095
5306
  // src/components/TextEntity.ts
5096
5307
  import {
@@ -5859,6 +6070,7 @@ export {
5859
6070
  ComputeParticleEntity,
5860
6071
  DOMPortalEntity,
5861
6072
  Entity,
6073
+ GlyphRasterAtlas,
5862
6074
  GridTextEntity,
5863
6075
  Group,
5864
6076
  MSDFTextEntity,
@@ -111,6 +111,8 @@ export declare class CanvasRenderer implements IRenderer {
111
111
  /** @inheritdoc */
112
112
  drawImage(source: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
113
113
  /** @inheritdoc */
114
+ drawImageRect(source: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
115
+ /** @inheritdoc */
114
116
  fillCircle(cx: number, cy: number, radius: number, color: string, alpha?: number): void;
115
117
  /** @inheritdoc */
116
118
  flush(): void;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * A texture atlas of rasterized glyphs, for grids that draw the *same small set
3
+ * of glyphs* thousands of times per frame.
4
+ *
5
+ * Named `GlyphRasterAtlas` rather than `GlyphAtlas` because `@vectojs/layout`
6
+ * already exports a `GlyphAtlas` interface — a map of grapheme to *vector* path
7
+ * metrics — and the core barrel re-exports that package, so the shorter name is a
8
+ * hard collision. The distinction is also worth keeping: that one holds path data
9
+ * for measuring, this one holds pixels for blitting.
10
+ *
11
+ * ## Why this exists alongside {@link TextRasterCache}
12
+ *
13
+ * Both replace per-cell `fillText` with a bitmap blit. The difference is where
14
+ * the pixels live, and measurement says that difference decides whether the idea
15
+ * works at all.
16
+ *
17
+ * `TextRasterCache` allocates **one canvas per cached run**. A warm cache for a
18
+ * syntax-highlighted code grid holds a few hundred of them (glyphs x theme
19
+ * colours), so a frame blits from a few hundred distinct source textures and the
20
+ * GPU re-binds on nearly every call. Measured on real hardware, that per-source
21
+ * cost is invisible at 2k cells and dominant at 40k: Chrome went 1.82x at 2k to
22
+ * **0.87x at 40k** — slower than the `fillText` it replaced. Its per-call cost
23
+ * grows with cell count (1.22 -> 2.89 us) rather than staying flat.
24
+ *
25
+ * This atlas keeps every glyph in **one** canvas and selects with a source rect,
26
+ * so the source texture never changes. Same call count, same pixels, same
27
+ * geometry — and per-call cost is flat as the grid grows (Chrome 1.10 -> 1.11
28
+ * us), giving **1.90-2.27x over `fillText` on both engines at every size**.
29
+ * Full data: `vectojs-docs/forge/baselines/raster-cache-findings.md`.
30
+ *
31
+ * The win comes from *reuse*, so this is for bounded glyph sets: a monospace code
32
+ * grid, a terminal, a data grid, a numeric HUD. Prose is the wrong customer —
33
+ * every run is distinct, so an atlas is pure overhead (use `RichText`'s coalesced
34
+ * runs there instead).
35
+ *
36
+ * ## Requires a source-rect blit
37
+ *
38
+ * Selecting one slot needs {@link IRenderer.drawImageRect}, which is optional:
39
+ * `CanvasRenderer` implements it, `SVGRenderer` deliberately does not (an SVG
40
+ * blit embeds its source as a data URL, so a per-cell sub-rect would inline the
41
+ * whole atlas thousands of times — and vector text is the correct output for a
42
+ * vector export anyway). Callers must keep their `fillText` path for renderers
43
+ * that lack it:
44
+ *
45
+ * ```ts
46
+ * const slot = atlas.get(font, color, glyph);
47
+ * if (slot && r.drawImageRect) {
48
+ * r.drawImageRect(atlas.source, slot.sx, slot.sy, slot.sw, slot.sh,
49
+ * x - slot.offsetX, baselineY - slot.offsetY, slot.w, slot.h);
50
+ * } else {
51
+ * r.fillText(glyph, x, baselineY, font, color);
52
+ * }
53
+ * ```
54
+ */
55
+ /** Where one glyph lives in the atlas, and how to blit it at a baseline. */
56
+ export interface GlyphSlot {
57
+ /** Source X in atlas *device* pixels. */
58
+ sx: number;
59
+ /** Source Y in atlas *device* pixels. */
60
+ sy: number;
61
+ /** Source width in atlas *device* pixels. */
62
+ sw: number;
63
+ /** Source height in atlas *device* pixels. */
64
+ sh: number;
65
+ /** Destination width in CSS pixels. */
66
+ w: number;
67
+ /** Destination height in CSS pixels. */
68
+ h: number;
69
+ /** Left inset (CSS px) of the glyph origin inside the slot. */
70
+ offsetX: number;
71
+ /** Distance (CSS px) from the slot top down to the text baseline. */
72
+ offsetY: number;
73
+ /** The cluster these pixels represent. */
74
+ glyph: string;
75
+ /** The CSS font shorthand these pixels were rasterized with. */
76
+ font: string;
77
+ /** Advance width (CSS px) of the cluster, i.e. `measureText().width`. */
78
+ advance: number;
79
+ /**
80
+ * Ink extent left of the glyph origin (CSS px), from `actualBoundingBoxLeft`.
81
+ *
82
+ * Carried on the slot so a blit can be mapped back to the same geometry a
83
+ * `fillText` would have produced. Without it, instrumentation that traces draw
84
+ * calls to verify grid positioning (`e2e/text-projection.e2e.ts`) can see only
85
+ * a destination rect and cannot recover where the glyph origin sat inside it.
86
+ */
87
+ left: number;
88
+ /** Ink extent right of the glyph origin (CSS px), from `actualBoundingBoxRight`. */
89
+ right: number;
90
+ }
91
+ /** Instrumentation counters, e.g. to surface a HUD hit rate. */
92
+ export interface GlyphRasterAtlasStats {
93
+ /** Requests served from an existing slot. */
94
+ hits: number;
95
+ /** Requests that had to rasterize. */
96
+ misses: number;
97
+ /** Glyphs currently resident. */
98
+ size: number;
99
+ /**
100
+ * Times the atlas filled up and was reset.
101
+ *
102
+ * Steady-state thrash means the glyph set is unbounded for the configured
103
+ * size, and the atlas is doing net harm — every reset re-rasterizes everything.
104
+ * A caller that watches this can fall back to `fillText` permanently.
105
+ */
106
+ resets: number;
107
+ }
108
+ /** Options for {@link GlyphRasterAtlas}. */
109
+ export interface GlyphRasterAtlasOptions {
110
+ /**
111
+ * Device-pixel-ratio to rasterize at. Slots record device pixels while `w`/`h`
112
+ * stay in CSS pixels, so the blit is DPR-correct without caller arithmetic.
113
+ * Default `1`.
114
+ */
115
+ dpr?: number;
116
+ /**
117
+ * Max atlas edge in device pixels, capped at 8192 — comfortably inside the
118
+ * lowest common `maxTextureSize` while leaving room for thousands of glyphs.
119
+ * Exceeding a browser's real limit yields a silently blank canvas, so this is
120
+ * clamped rather than trusted. Default `2048`.
121
+ */
122
+ maxSize?: number;
123
+ }
124
+ /**
125
+ * A glyph atlas. Create one per renderer/scene — instances share no state, so
126
+ * multiple scenes or an SSR pass never collide.
127
+ */
128
+ export declare class GlyphRasterAtlas {
129
+ private readonly slots;
130
+ private readonly dpr;
131
+ private readonly maxSize;
132
+ private canvas;
133
+ private ctx;
134
+ /**
135
+ * Shelf packing: glyphs land left-to-right on a row, then a new row starts.
136
+ * A monospace grid produces near-uniform widths, so shelves waste very little
137
+ * and cost one comparison per insert — a real 2D packer would buy nothing here.
138
+ */
139
+ private penX;
140
+ private penY;
141
+ private rowHeight;
142
+ private _hits;
143
+ private _misses;
144
+ private _resets;
145
+ constructor(options?: GlyphRasterAtlasOptions);
146
+ /** Live instrumentation snapshot. */
147
+ get stats(): GlyphRasterAtlasStats;
148
+ /**
149
+ * The atlas canvas, to pass as the blit source.
150
+ *
151
+ * `null` until the first successful {@link get}, and in any non-DOM context.
152
+ */
153
+ get source(): HTMLCanvasElement | null;
154
+ private ensureCanvas;
155
+ /**
156
+ * Look up a glyph, rasterizing it into the atlas on first request.
157
+ *
158
+ * @param font - Full CSS `font` shorthand, used for measuring and painting.
159
+ * @param color - CSS color baked into the pixels.
160
+ * @param glyph - A single grapheme cluster. Long strings are rejected
161
+ * (`null`): they defeat the atlas's fixed-slot packing and belong in
162
+ * `fillText` or {@link TextRasterCache}.
163
+ * @returns The slot, or `null` when the caller must fall back to `fillText`
164
+ * (headless, unrasterizable, or too large to pack).
165
+ */
166
+ get(font: string, color: string, glyph: string): GlyphSlot | null;
167
+ /**
168
+ * Find the slot occupying a source position, or `null`.
169
+ *
170
+ * The inverse of {@link get}: it maps a blit back to the glyph it drew. Exists
171
+ * for instrumentation — a test or devtool that traces `drawImage` calls sees
172
+ * only a source rect, and needs this to recover which cluster was painted and
173
+ * with what metrics. Linear over resident slots, so it is a diagnostic, not a
174
+ * per-frame call.
175
+ */
176
+ slotAt(sx: number, sy: number): GlyphSlot | null;
177
+ /**
178
+ * Drop every glyph and reuse the canvas.
179
+ *
180
+ * Call after a font or theme change: slots are keyed by `(font, color, glyph)`
181
+ * so stale entries are never *returned* wrongly, but they do occupy space.
182
+ */
183
+ reset(): void;
184
+ /** Release the backing canvas and all slots. */
185
+ destroy(): void;
186
+ }
@@ -115,6 +115,37 @@ export interface IRenderer {
115
115
  * @param dh - Destination height.
116
116
  */
117
117
  drawImage(source: CanvasImageSource, dx: number, dy: number, dw: number, dh: number): void;
118
+ /**
119
+ * Draw a sub-rectangle of an image source — the 9-argument `drawImage`.
120
+ *
121
+ * **Optional.** Callers must feature-detect and keep a fallback path:
122
+ *
123
+ * ```ts
124
+ * if (r.drawImageRect) r.drawImageRect(atlas, sx, sy, sw, sh, dx, dy, dw, dh);
125
+ * else r.fillText(glyph, x, baselineY, font, color);
126
+ * ```
127
+ *
128
+ * This exists for texture atlases (see `GlyphRasterAtlas`), where selecting one slot
129
+ * out of a shared canvas is what makes the blit cheap: a per-source-canvas
130
+ * cache re-binds a different texture almost every call and measured *slower*
131
+ * than the `fillText` it replaced on Chrome at scale, while atlas blits stay
132
+ * flat and run ~2x faster on both engines.
133
+ *
134
+ * `SVGRenderer` deliberately omits it: an SVG image embeds its source as a data
135
+ * URL, so a per-cell sub-rect would inline the entire atlas once per cell —
136
+ * and vector text is the correct output for a vector export regardless.
137
+ *
138
+ * @param source - The image source.
139
+ * @param sx - Source X, in source-image pixels.
140
+ * @param sy - Source Y, in source-image pixels.
141
+ * @param sw - Source width, in source-image pixels.
142
+ * @param sh - Source height, in source-image pixels.
143
+ * @param dx - Destination X.
144
+ * @param dy - Destination Y.
145
+ * @param dw - Destination width.
146
+ * @param dh - Destination height.
147
+ */
148
+ drawImageRect?(source: CanvasImageSource, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): void;
118
149
  /**
119
150
  * Fill the current path with the given color or gradient.
120
151
  *
@@ -4,4 +4,5 @@ export * from './WebGLPointRenderer';
4
4
  export * from './WebGPUParticleSystemManager';
5
5
  export * from './IRenderer';
6
6
  export * from './colorParse';
7
+ export * from './GlyphRasterAtlas';
7
8
  export * from './TextRasterCache';
package/dist/renderer.js CHANGED
@@ -6,12 +6,14 @@
6
6
 
7
7
 
8
8
 
9
- var _chunkL4SWVP2Hjs = require('./chunk-L4SWVP2H.js');
10
9
 
10
+ var _chunkGKSCJ6AFjs = require('./chunk-GKSCJ6AF.js');
11
11
 
12
12
 
13
13
 
14
14
 
15
15
 
16
16
 
17
- exports.CanvasRenderer = _chunkL4SWVP2Hjs.CanvasRenderer; exports.SVGRenderer = _chunkL4SWVP2Hjs.SVGRenderer; exports.TextRasterCache = _chunkL4SWVP2Hjs.TextRasterCache; exports.WebGPUParticleSystemManager = _chunkL4SWVP2Hjs.WebGPUParticleSystemManager; exports.createWebGLPointRenderer = _chunkL4SWVP2Hjs.createWebGLPointRenderer; exports.parseColorToRGBA = _chunkL4SWVP2Hjs.parseColorToRGBA;
17
+
18
+
19
+ exports.CanvasRenderer = _chunkGKSCJ6AFjs.CanvasRenderer; exports.GlyphRasterAtlas = _chunkGKSCJ6AFjs.GlyphRasterAtlas; exports.SVGRenderer = _chunkGKSCJ6AFjs.SVGRenderer; exports.TextRasterCache = _chunkGKSCJ6AFjs.TextRasterCache; exports.WebGPUParticleSystemManager = _chunkGKSCJ6AFjs.WebGPUParticleSystemManager; exports.createWebGLPointRenderer = _chunkGKSCJ6AFjs.createWebGLPointRenderer; exports.parseColorToRGBA = _chunkGKSCJ6AFjs.parseColorToRGBA;