@vectojs/core 1.12.0 → 1.14.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
@@ -667,16 +667,29 @@ var Scene = (_class2 = class _Scene {
667
667
  __init10() {this.dirty = true}
668
668
  /** Whether to throttle rendering to 2 FPS when the scene is static to save power. */
669
669
  __init11() {this.autoThrottle = true}
670
+ // --- Frame telemetry (read via `frameStats`) ---------------------------
671
+ /** Wall-clock ms spent inside the last `render()` call. */
672
+ __init12() {this._lastFrameMs = 0}
673
+ /** Rolling exponential average of rendered-frame intervals, in ms. */
674
+ __init13() {this._avgFrameIntervalMs = 0}
675
+ /** dt (ms) handed to the last rendered frame. */
676
+ __init14() {this._lastDt = 0}
677
+ /** Count of frames actually rendered since the loop started. */
678
+ __init15() {this._renderedFrames = 0}
679
+ /** Count of rAF ticks skipped (idle / capped) since the loop started. */
680
+ __init16() {this._skippedFrames = 0}
681
+ /** `time` of the previous *rendered* frame, for interval measurement. */
682
+ __init17() {this._lastRenderTick = 0}
670
683
  /**
671
684
  * Frame-rate cap (power saving). `0` = uncapped (native refresh). When set,
672
685
  * the loop renders at most `maxFPS` times per second; animations still run,
673
686
  * just less often. See {@link SceneOptions.maxFPS}.
674
687
  */
675
- __init12() {this.maxFPS = 60}
688
+ __init18() {this.maxFPS = 60}
676
689
  /** Whether the OS prefers-reduced-motion setting auto-caps the loop. */
677
- __init13() {this.respectReducedMotion = true}
690
+ __init19() {this.respectReducedMotion = true}
678
691
  /** Cached media-query list; `.matches` is read live each frame. */
679
- __init14() {this.reducedMotionQuery = null}
692
+ __init20() {this.reducedMotionQuery = null}
680
693
  /** True when the OS asks for reduced motion and we respect it. Read by the animation drivers. */
681
694
  get prefersReducedMotion() {
682
695
  return this.respectReducedMotion && !!_optionalChain([this, 'access', _21 => _21.reducedMotionQuery, 'optionalAccess', _22 => _22.matches]);
@@ -685,95 +698,98 @@ var Scene = (_class2 = class _Scene {
685
698
  * Throttle interval (ms) for the a11y/automation shadow sync. `0` = every
686
699
  * frame. See {@link SceneOptions.a11ySyncInterval}.
687
700
  */
688
- __init15() {this.a11ySyncInterval = 0}
701
+ __init21() {this.a11ySyncInterval = 0}
689
702
  /** Timestamp of the last a11y sync, for throttling. */
690
- __init16() {this.lastA11ySync = -Infinity}
703
+ __init22() {this.lastA11ySync = -Infinity}
691
704
  /** True if we skipped an a11y sync during animation and need to sync when at rest. */
692
- __init17() {this.a11yPendingSyncAfterAnimation = false}
705
+ __init23() {this.a11yPendingSyncAfterAnimation = false}
693
706
  // A11y / Automation Layer. `null` in non-DOM (SSR/Node) environments — the
694
707
  // whole projection degrades to a no-op so the engine's logic stays usable
695
708
  // server-side (e.g. headless layout / vector export) without jsdom.
696
709
 
697
- __init18() {this.a11yElements = /* @__PURE__ */ new Map()}
710
+ __init24() {this.a11yElements = /* @__PURE__ */ new Map()}
698
711
  /** DOM nodes mirroring static text content, keyed by entity id. */
699
- __init19() {this.contentElements = /* @__PURE__ */ new Map()}
712
+ __init25() {this.contentElements = /* @__PURE__ */ new Map()}
700
713
  /** Pending cold font-calibration frame per projected grid entity. */
701
- __init20() {this.contentGridCalibrationFrames = /* @__PURE__ */ new Map()}
714
+ __init26() {this.contentGridCalibrationFrames = /* @__PURE__ */ new Map()}
702
715
  /** Detached, untransformed font probes used by the cold calibration pass. */
703
- __init21() {this.contentGridCalibrationProbes = /* @__PURE__ */ new Map()}
716
+ __init27() {this.contentGridCalibrationProbes = /* @__PURE__ */ new Map()}
704
717
  /** Invalidates grid font calibration after browser font availability changes. */
705
- __init22() {this.contentFontEpoch = 0}
718
+ __init28() {this.contentFontEpoch = 0}
706
719
  /** Cached Canvas-to-client scale for the current font/viewport epoch. */
707
- __init23() {this.contentMetricScaleEpoch = -1}
708
- __init24() {this.contentMetricScaleX = 1}
709
- __init25() {this.contentProjectionEnabled = true}
720
+ __init29() {this.contentMetricScaleEpoch = -1}
721
+ __init30() {this.contentMetricScaleX = 1}
722
+ __init31() {this.contentProjectionEnabled = true}
723
+ // Virtualization margin (px) for content projection; `undefined` → one
724
+ // viewport height, resolved at sync time. `Infinity` = materialize everything.
725
+ __init32() {this.contentProjectionMargin = void 0}
710
726
  /**
711
727
  * True while a text-selection drag that started on a projection's blank
712
728
  * region (no text node under the press) is being driven manually — the
713
729
  * browser has no native anchor for it, so mousemove extends the Selection
714
730
  * from the position we resolved ourselves.
715
731
  */
716
- __init26() {this.blankRegionSelectionDrag = false}
717
- __init27() {this.contentSelectionAnchor = null}
718
- __init28() {this.contentSelectionEndListener = null}
732
+ __init33() {this.blankRegionSelectionDrag = false}
733
+ __init34() {this.contentSelectionAnchor = null}
734
+ __init35() {this.contentSelectionEndListener = null}
719
735
  // Animation/interactive flags collected during the render walk (tree-walk
720
736
  // fusion): the loop reads last frame's answers instead of re-walking the
721
737
  // tree up to 4× per tick. Start true so the first tick stays conservative.
722
- __init29() {this.frameHadAnimation = true}
723
- __init30() {this.frameHadInteractive = true}
738
+ __init36() {this.frameHadAnimation = true}
739
+ __init37() {this.frameHadInteractive = true}
724
740
 
725
- __init31() {this.focusedA11yElement = null}
726
- __init32() {this.caretBlinkTimer = null}
727
- __init33() {this.a11yNeedsReorder = true}
728
- __init34() {this.portalRoot = null}
729
- __init35() {this.fullViewportElements = []}
730
- __init36() {this.normalElements = []}
731
- __init37() {this.activeIds = /* @__PURE__ */ new Set()}
732
- __init38() {this.activePortalsThisFrame = /* @__PURE__ */ new Set()}
733
- __init39() {this.activePortalsPrevFrame = /* @__PURE__ */ new Set()}
734
- __init40() {this.portalEntities = /* @__PURE__ */ new Map()}
735
- __init41() {this.renderOrderCounter = 0}
741
+ __init38() {this.focusedA11yElement = null}
742
+ __init39() {this.caretBlinkTimer = null}
743
+ __init40() {this.a11yNeedsReorder = true}
744
+ __init41() {this.portalRoot = null}
745
+ __init42() {this.fullViewportElements = []}
746
+ __init43() {this.normalElements = []}
747
+ __init44() {this.activeIds = /* @__PURE__ */ new Set()}
748
+ __init45() {this.activePortalsThisFrame = /* @__PURE__ */ new Set()}
749
+ __init46() {this.activePortalsPrevFrame = /* @__PURE__ */ new Set()}
750
+ __init47() {this.portalEntities = /* @__PURE__ */ new Map()}
751
+ __init48() {this.renderOrderCounter = 0}
736
752
  /**
737
753
  * Authoritative paint order for semantic nodes discovered during the main
738
754
  * render. A node may not have a DOM projection until the following a11y
739
755
  * sync, so retaining the order prevents a newly opened overlay from spending
740
756
  * its first frame below previously projected controls.
741
757
  */
742
- __init42() {this.a11yRenderOrders = /* @__PURE__ */ new Map()}
758
+ __init49() {this.a11yRenderOrders = /* @__PURE__ */ new Map()}
743
759
  // Optional WebGL point-cloud layer (see SceneOptions.pointBackend).
744
- __init43() {this.pointRenderer = null}
745
- __init44() {this.glCanvas = null}
760
+ __init50() {this.pointRenderer = null}
761
+ __init51() {this.glCanvas = null}
746
762
 
747
763
 
748
764
 
749
- __init45() {this.disableWindowResize = false}
765
+ __init52() {this.disableWindowResize = false}
750
766
  /** See {@link SceneOptions.maxDPR}. `undefined` = uncapped (real DPR). */
751
767
 
752
768
  // WebGPU properties
753
- __init46() {this.destroyed = false}
754
- __init47() {this.device = null}
755
- __init48() {this.deviceLost = false}
756
- __init49() {this.particleBackend = "auto"}
757
- __init50() {this._webgpuDisabled = false}
769
+ __init53() {this.destroyed = false}
770
+ __init54() {this.device = null}
771
+ __init55() {this.deviceLost = false}
772
+ __init56() {this.particleBackend = "auto"}
773
+ __init57() {this._webgpuDisabled = false}
758
774
  get webgpuDisabled() {
759
775
  return this._webgpuDisabled || this.particleBackend === "cpu";
760
776
  }
761
777
  set webgpuDisabled(value) {
762
778
  this._webgpuDisabled = value;
763
779
  }
764
- __init51() {this.recoveryTimerId = null}
765
- __init52() {this.manager = null}
766
- __init53() {this.initializingWebGPU = false}
767
- __init54() {this.gpuCanvas = null}
768
- __init55() {this.gpuContext = null}
780
+ __init58() {this.recoveryTimerId = null}
781
+ __init59() {this.manager = null}
782
+ __init60() {this.initializingWebGPU = false}
783
+ __init61() {this.gpuCanvas = null}
784
+ __init62() {this.gpuContext = null}
769
785
  /** True while the GPU canvas holds a presented particle frame (needs clearing when they leave). */
770
- __init56() {this.gpuHasContent = false}
771
- __init57() {this.mouseX = -9999}
772
- __init58() {this.mouseY = -9999}
773
- __init59() {this.pointerMoveListener = null}
774
- __init60() {this.pointerLeaveListener = null}
775
- __init61() {this.hasWarnedZeroSize = false}
776
- __init62() {this.fontLoadHandler = null}
786
+ __init63() {this.gpuHasContent = false}
787
+ __init64() {this.mouseX = -9999}
788
+ __init65() {this.mouseY = -9999}
789
+ __init66() {this.pointerMoveListener = null}
790
+ __init67() {this.pointerLeaveListener = null}
791
+ __init68() {this.hasWarnedZeroSize = false}
792
+ __init69() {this.fontLoadHandler = null}
777
793
  // ── Dev-mode warning infrastructure ──────────────────────────────
778
794
  //
779
795
  // Enable with `Scene.devMode = true` or by setting `globalThis.__DEV__`.
@@ -791,7 +807,7 @@ var Scene = (_class2 = class _Scene {
791
807
  return false;
792
808
  }
793
809
 
794
- __init63() {this._devFrameCount = 0}
810
+ __init70() {this._devFrameCount = 0}
795
811
  _devWarn(message) {
796
812
  if (!this._devActive) return;
797
813
  console.warn(`[vectojs/dev] ${message}`);
@@ -835,7 +851,7 @@ var Scene = (_class2 = class _Scene {
835
851
  };
836
852
  walkProjections(this.root);
837
853
  }
838
- constructor(canvas, options = {}) {;_class2.prototype.__init7.call(this);_class2.prototype.__init8.call(this);_class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this);_class2.prototype.__init13.call(this);_class2.prototype.__init14.call(this);_class2.prototype.__init15.call(this);_class2.prototype.__init16.call(this);_class2.prototype.__init17.call(this);_class2.prototype.__init18.call(this);_class2.prototype.__init19.call(this);_class2.prototype.__init20.call(this);_class2.prototype.__init21.call(this);_class2.prototype.__init22.call(this);_class2.prototype.__init23.call(this);_class2.prototype.__init24.call(this);_class2.prototype.__init25.call(this);_class2.prototype.__init26.call(this);_class2.prototype.__init27.call(this);_class2.prototype.__init28.call(this);_class2.prototype.__init29.call(this);_class2.prototype.__init30.call(this);_class2.prototype.__init31.call(this);_class2.prototype.__init32.call(this);_class2.prototype.__init33.call(this);_class2.prototype.__init34.call(this);_class2.prototype.__init35.call(this);_class2.prototype.__init36.call(this);_class2.prototype.__init37.call(this);_class2.prototype.__init38.call(this);_class2.prototype.__init39.call(this);_class2.prototype.__init40.call(this);_class2.prototype.__init41.call(this);_class2.prototype.__init42.call(this);_class2.prototype.__init43.call(this);_class2.prototype.__init44.call(this);_class2.prototype.__init45.call(this);_class2.prototype.__init46.call(this);_class2.prototype.__init47.call(this);_class2.prototype.__init48.call(this);_class2.prototype.__init49.call(this);_class2.prototype.__init50.call(this);_class2.prototype.__init51.call(this);_class2.prototype.__init52.call(this);_class2.prototype.__init53.call(this);_class2.prototype.__init54.call(this);_class2.prototype.__init55.call(this);_class2.prototype.__init56.call(this);_class2.prototype.__init57.call(this);_class2.prototype.__init58.call(this);_class2.prototype.__init59.call(this);_class2.prototype.__init60.call(this);_class2.prototype.__init61.call(this);_class2.prototype.__init62.call(this);_class2.prototype.__init63.call(this);
854
+ constructor(canvas, options = {}) {;_class2.prototype.__init7.call(this);_class2.prototype.__init8.call(this);_class2.prototype.__init9.call(this);_class2.prototype.__init10.call(this);_class2.prototype.__init11.call(this);_class2.prototype.__init12.call(this);_class2.prototype.__init13.call(this);_class2.prototype.__init14.call(this);_class2.prototype.__init15.call(this);_class2.prototype.__init16.call(this);_class2.prototype.__init17.call(this);_class2.prototype.__init18.call(this);_class2.prototype.__init19.call(this);_class2.prototype.__init20.call(this);_class2.prototype.__init21.call(this);_class2.prototype.__init22.call(this);_class2.prototype.__init23.call(this);_class2.prototype.__init24.call(this);_class2.prototype.__init25.call(this);_class2.prototype.__init26.call(this);_class2.prototype.__init27.call(this);_class2.prototype.__init28.call(this);_class2.prototype.__init29.call(this);_class2.prototype.__init30.call(this);_class2.prototype.__init31.call(this);_class2.prototype.__init32.call(this);_class2.prototype.__init33.call(this);_class2.prototype.__init34.call(this);_class2.prototype.__init35.call(this);_class2.prototype.__init36.call(this);_class2.prototype.__init37.call(this);_class2.prototype.__init38.call(this);_class2.prototype.__init39.call(this);_class2.prototype.__init40.call(this);_class2.prototype.__init41.call(this);_class2.prototype.__init42.call(this);_class2.prototype.__init43.call(this);_class2.prototype.__init44.call(this);_class2.prototype.__init45.call(this);_class2.prototype.__init46.call(this);_class2.prototype.__init47.call(this);_class2.prototype.__init48.call(this);_class2.prototype.__init49.call(this);_class2.prototype.__init50.call(this);_class2.prototype.__init51.call(this);_class2.prototype.__init52.call(this);_class2.prototype.__init53.call(this);_class2.prototype.__init54.call(this);_class2.prototype.__init55.call(this);_class2.prototype.__init56.call(this);_class2.prototype.__init57.call(this);_class2.prototype.__init58.call(this);_class2.prototype.__init59.call(this);_class2.prototype.__init60.call(this);_class2.prototype.__init61.call(this);_class2.prototype.__init62.call(this);_class2.prototype.__init63.call(this);_class2.prototype.__init64.call(this);_class2.prototype.__init65.call(this);_class2.prototype.__init66.call(this);_class2.prototype.__init67.call(this);_class2.prototype.__init68.call(this);_class2.prototype.__init69.call(this);_class2.prototype.__init70.call(this);
839
855
  this.canvas = canvas;
840
856
  this.debugA11y = _nullishCoalesce(options.debugA11y, () => ( false));
841
857
  this.disableWindowResize = _nullishCoalesce(options.disableWindowResize, () => ( false));
@@ -857,6 +873,7 @@ var Scene = (_class2 = class _Scene {
857
873
  this.particleBackend = _nullishCoalesce(options.particleBackend, () => ( "auto"));
858
874
  this.a11ySyncInterval = _nullishCoalesce(options.a11ySyncInterval, () => ( 0));
859
875
  this.contentProjectionEnabled = _nullishCoalesce(options.contentProjection, () => ( true));
876
+ this.contentProjectionMargin = options.contentProjectionMargin;
860
877
  this._devActive = _Scene._devModeDetected();
861
878
  this.reducedMotionQuery = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
862
879
  this.root = new class RootEntity extends _chunk2Z23LTH3js.Entity {
@@ -1318,6 +1335,36 @@ var Scene = (_class2 = class _Scene {
1318
1335
  markDirty() {
1319
1336
  this.dirty = true;
1320
1337
  }
1338
+ /**
1339
+ * Live frame telemetry for profilers and devtools overlays. All timings are
1340
+ * measured on the `requestAnimationFrame` loop; a scene driven only by
1341
+ * {@link step} (e.g. deterministic video export) leaves these at their zero
1342
+ * defaults.
1343
+ *
1344
+ * `fps` is derived from the interval between *rendered* frames, so idle
1345
+ * `onDemand` scenes and frames skipped by the {@link maxFPS} cap or the
1346
+ * static auto-throttle do not deflate it — it reports the cadence of actual
1347
+ * redraws, not the raw rAF rate. `frameTimeMs` is the wall-clock cost of the
1348
+ * last `render()` pass alone (excludes a11y/content-projection sync).
1349
+ *
1350
+ * The renderer always repaints the full canvas, so there is no partial
1351
+ * dirty-rectangle to expose; `dirty` is the boolean redraw-pending flag and
1352
+ * `pendingRedraw` reflects whether the next `onDemand` tick will actually
1353
+ * render.
1354
+ */
1355
+ get frameStats() {
1356
+ const interval = this._avgFrameIntervalMs;
1357
+ return {
1358
+ fps: interval > 0 ? Math.min(1e3 / interval, this.maxFPS > 0 ? this.maxFPS : 1e3 / interval) : 0,
1359
+ frameTimeMs: this._lastFrameMs,
1360
+ frameIntervalMs: interval,
1361
+ dt: this._lastDt,
1362
+ renderedFrames: this._renderedFrames,
1363
+ skippedFrames: this._skippedFrames,
1364
+ renderMode: this.renderMode,
1365
+ dirty: this.dirty
1366
+ };
1367
+ }
1321
1368
  /** True when any node in the subtree has a pending animation. */
1322
1369
  /** True when any node in the subtree is interactive (drives a11y sync). */
1323
1370
  syncOptionalAttribute(element, name, value) {
@@ -1618,6 +1665,56 @@ var Scene = (_class2 = class _Scene {
1618
1665
  * hidden (`display: none`) so text-heavy scenes only materialize what is
1619
1666
  * visible to the browser's text machinery anyway.
1620
1667
  */
1668
+ /**
1669
+ * Whether `node`'s world-space box, expanded by `margin` px on every side,
1670
+ * overlaps the scene viewport AND every `clipChildren` ancestor's box. Used
1671
+ * both to virtualize content projection (materialize only near-viewport text,
1672
+ * at `margin = contentProjectionMargin`) and for the exact `display:none`
1673
+ * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
1674
+ * culling and always count as visible, matching the legacy behavior.
1675
+ */
1676
+ projectionBoxVisible(node, tf, margin) {
1677
+ if (!(node.width > 0 && node.height > 0)) return true;
1678
+ const { a, b, c, d, e, f } = tf;
1679
+ const worldCorners = [];
1680
+ let minX = Infinity;
1681
+ let minY = Infinity;
1682
+ let maxX = -Infinity;
1683
+ let maxY = -Infinity;
1684
+ for (let i = 0; i < 4; i++) {
1685
+ const lx = i & 1 ? node.width : 0;
1686
+ const ly = i & 2 ? node.height : 0;
1687
+ const wx = a * lx + c * ly + e;
1688
+ const wy = b * lx + d * ly + f;
1689
+ worldCorners.push({ x: wx, y: wy });
1690
+ if (wx < minX) minX = wx;
1691
+ if (wx > maxX) maxX = wx;
1692
+ if (wy < minY) minY = wy;
1693
+ if (wy > maxY) maxY = wy;
1694
+ }
1695
+ if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
1696
+ return false;
1697
+ }
1698
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
1699
+ if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
1700
+ let localMinX = Infinity;
1701
+ let localMinY = Infinity;
1702
+ let localMaxX = -Infinity;
1703
+ let localMaxY = -Infinity;
1704
+ for (const corner of worldCorners) {
1705
+ const local = ancestor.worldToLocal(corner.x, corner.y);
1706
+ if (!local) continue;
1707
+ localMinX = Math.min(localMinX, local.x);
1708
+ localMinY = Math.min(localMinY, local.y);
1709
+ localMaxX = Math.max(localMaxX, local.x);
1710
+ localMaxY = Math.max(localMaxY, local.y);
1711
+ }
1712
+ if (!(localMaxX >= -margin && localMinX <= ancestor.width + margin && localMaxY >= -margin && localMinY <= ancestor.height + margin)) {
1713
+ return false;
1714
+ }
1715
+ }
1716
+ return true;
1717
+ }
1621
1718
  syncContentProjection(node) {
1622
1719
  if (!this.contentProjectionEnabled || !this.a11yRoot) return;
1623
1720
  const projection = node.getContentProjection();
@@ -1631,6 +1728,17 @@ var Scene = (_class2 = class _Scene {
1631
1728
  }
1632
1729
  return;
1633
1730
  }
1731
+ const worldTf = node.getWorldTransform();
1732
+ const margin = _nullishCoalesce(this.contentProjectionMargin, () => ( this.height));
1733
+ if (Number.isFinite(margin) && !this.projectionBoxVisible(node, worldTf, margin)) {
1734
+ if (el) {
1735
+ this.clearContentGridState(node.id, el);
1736
+ el.remove();
1737
+ this.contentElements.delete(node.id);
1738
+ this.a11yNeedsReorder = true;
1739
+ }
1740
+ return;
1741
+ }
1634
1742
  if (!el) {
1635
1743
  el = document.createElement("div");
1636
1744
  el.setAttribute("data-vecto-content", node.id);
@@ -1727,7 +1835,7 @@ var Scene = (_class2 = class _Scene {
1727
1835
  el.style.userSelect = selectable ? "text" : "none";
1728
1836
  el.style.cursor = selectable ? "text" : "";
1729
1837
  }
1730
- const { a, b, c, d, e, f } = node.getWorldTransform();
1838
+ const { a, b, c, d, e, f } = worldTf;
1731
1839
  const contentX = _nullishCoalesce(projection.contentX, () => ( 0));
1732
1840
  const contentY = _nullishCoalesce(projection.contentY, () => ( 0));
1733
1841
  const baselineOffset = lines && lines.length > 0 ? 0 : projection.baseline === void 0 ? 0 : projection.baseline - _chunk2Z23LTH3js.cssLineBoxBaseline.call(void 0, font, _nullishCoalesce(projection.lineHeight, () => ( 16)));
@@ -1737,42 +1845,7 @@ var Scene = (_class2 = class _Scene {
1737
1845
  if (node.width > 0) el.style.width = `${node.width}px`;
1738
1846
  if (node.height > 0) el.style.height = `${node.height}px`;
1739
1847
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
1740
- let visible = true;
1741
- if (node.width > 0 && node.height > 0) {
1742
- const worldCorners = [];
1743
- let minX = Infinity;
1744
- let minY = Infinity;
1745
- let maxX = -Infinity;
1746
- let maxY = -Infinity;
1747
- for (let i = 0; i < 4; i++) {
1748
- const lx = i & 1 ? node.width : 0;
1749
- const ly = i & 2 ? node.height : 0;
1750
- const wx = a * lx + c * ly + e;
1751
- const wy = b * lx + d * ly + f;
1752
- worldCorners.push({ x: wx, y: wy });
1753
- if (wx < minX) minX = wx;
1754
- if (wx > maxX) maxX = wx;
1755
- if (wy < minY) minY = wy;
1756
- if (wy > maxY) maxY = wy;
1757
- }
1758
- visible = maxX >= 0 && minX <= this.width && maxY >= 0 && minY <= this.height;
1759
- for (let ancestor = node.parent; visible && ancestor; ancestor = ancestor.parent) {
1760
- if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
1761
- let localMinX = Infinity;
1762
- let localMinY = Infinity;
1763
- let localMaxX = -Infinity;
1764
- let localMaxY = -Infinity;
1765
- for (const corner of worldCorners) {
1766
- const local = ancestor.worldToLocal(corner.x, corner.y);
1767
- if (!local) continue;
1768
- localMinX = Math.min(localMinX, local.x);
1769
- localMinY = Math.min(localMinY, local.y);
1770
- localMaxX = Math.max(localMaxX, local.x);
1771
- localMaxY = Math.max(localMaxY, local.y);
1772
- }
1773
- visible = localMaxX >= 0 && localMinX <= ancestor.width && localMaxY >= 0 && localMinY <= ancestor.height;
1774
- }
1775
- }
1848
+ const visible = this.projectionBoxVisible(node, worldTf, 0);
1776
1849
  const display = visible ? "" : "none";
1777
1850
  if (el.style.display !== display) el.style.display = display;
1778
1851
  }
@@ -2221,6 +2294,7 @@ var Scene = (_class2 = class _Scene {
2221
2294
  cap = Math.min(cap, 2);
2222
2295
  }
2223
2296
  if (cap > 0 && time - this.lastTime < 1e3 / cap - 1) {
2297
+ this._skippedFrames++;
2224
2298
  this.scheduleFrame();
2225
2299
  return;
2226
2300
  }
@@ -2231,11 +2305,23 @@ var Scene = (_class2 = class _Scene {
2231
2305
  }
2232
2306
  this.lastTime = time;
2233
2307
  if (this.renderMode === "onDemand" && isIdle) {
2308
+ this._skippedFrames++;
2234
2309
  this.scheduleFrame();
2235
2310
  return;
2236
2311
  }
2237
2312
  this.dirty = false;
2313
+ const now = typeof performance !== "undefined" ? performance.now() : time;
2314
+ if (this._lastRenderTick > 0) {
2315
+ const interval = time - this._lastRenderTick;
2316
+ if (interval > 0) {
2317
+ this._avgFrameIntervalMs = this._avgFrameIntervalMs === 0 ? interval : this._avgFrameIntervalMs * 0.9 + interval * 0.1;
2318
+ }
2319
+ }
2320
+ this._lastRenderTick = time;
2321
+ this._lastDt = dt;
2238
2322
  this.render(this.renderer, dt, time);
2323
+ this._lastFrameMs = (typeof performance !== "undefined" ? performance.now() : time) - now;
2324
+ this._renderedFrames++;
2239
2325
  const hasActiveAnimation = this.frameHadAnimation;
2240
2326
  const hasInteractive = this.frameHadInteractive;
2241
2327
  const wantsContentSync = this.contentProjectionEnabled;
@@ -2762,15 +2848,15 @@ var TextEntity = (_class3 = class extends _chunk2Z23LTH3js.Entity {
2762
2848
 
2763
2849
 
2764
2850
 
2765
- __init64() {this.nodes = []}
2851
+ __init71() {this.nodes = []}
2766
2852
 
2767
- __init65() {this.fillStyle = "#94a3b8"}
2768
- __init66() {this.strokeStyle = null}
2769
- __init67() {this.hoveredFillStyle = "#ffffff"}
2770
- __init68() {this.lineWidth = 1}
2771
- __init69() {this.isHovered = false}
2853
+ __init72() {this.fillStyle = "#94a3b8"}
2854
+ __init73() {this.strokeStyle = null}
2855
+ __init74() {this.hoveredFillStyle = "#ffffff"}
2856
+ __init75() {this.lineWidth = 1}
2857
+ __init76() {this.isHovered = false}
2772
2858
  constructor(text, atlas, maxWidth, fontSize = 24) {
2773
- super();_class3.prototype.__init64.call(this);_class3.prototype.__init65.call(this);_class3.prototype.__init66.call(this);_class3.prototype.__init67.call(this);_class3.prototype.__init68.call(this);_class3.prototype.__init69.call(this);;
2859
+ super();_class3.prototype.__init71.call(this);_class3.prototype.__init72.call(this);_class3.prototype.__init73.call(this);_class3.prototype.__init74.call(this);_class3.prototype.__init75.call(this);_class3.prototype.__init76.call(this);;
2774
2860
  this.text = text;
2775
2861
  this.atlas = atlas;
2776
2862
  this.fontSize = fontSize;
@@ -2885,15 +2971,15 @@ var TextEntity = (_class3 = class extends _chunk2Z23LTH3js.Entity {
2885
2971
  // src/components/GridTextEntity.ts
2886
2972
  var GridTextEntity = (_class4 = class extends _chunk2Z23LTH3js.Entity {
2887
2973
 
2888
- __init70() {this.fillStyle = "#ffffff"}
2889
- __init71() {this.grid = []}
2974
+ __init77() {this.fillStyle = "#ffffff"}
2975
+ __init78() {this.grid = []}
2890
2976
  // Array of rows
2891
- __init72() {this.cols = 0}
2892
- __init73() {this.rows = 0}
2977
+ __init79() {this.cols = 0}
2978
+ __init80() {this.rows = 0}
2893
2979
 
2894
2980
 
2895
2981
  constructor(_atlas, fontSize = 10) {
2896
- super();_class4.prototype.__init70.call(this);_class4.prototype.__init71.call(this);_class4.prototype.__init72.call(this);_class4.prototype.__init73.call(this);;
2982
+ super();_class4.prototype.__init77.call(this);_class4.prototype.__init78.call(this);_class4.prototype.__init79.call(this);_class4.prototype.__init80.call(this);;
2897
2983
  this.fontSize = fontSize;
2898
2984
  this.charWidth = fontSize * 1;
2899
2985
  this.charHeight = fontSize * 1.1;
@@ -2985,23 +3071,23 @@ var SplineEntity = (_class5 = class extends _chunk2Z23LTH3js.Entity {
2985
3071
 
2986
3072
 
2987
3073
 
2988
- __init74() {this.offscreen = null}
2989
- __init75() {this.baked = false}
3074
+ __init81() {this.offscreen = null}
3075
+ __init82() {this.baked = false}
2990
3076
  /** Logical (CSS-pixel) size of the baked bitmap — the blit destination size. */
2991
- __init76() {this.bakedWidth = 0}
2992
- __init77() {this.bakedHeight = 0}
3077
+ __init83() {this.bakedWidth = 0}
3078
+ __init84() {this.bakedHeight = 0}
2993
3079
  /** Gradient strokes can't be baked to a solid-color bitmap; they render per-frame. */
2994
3080
 
2995
3081
  /** Lazily-flattened polylines (one Float32Array of [x,y,...] per segment) for hit-testing. */
2996
- __init78() {this.polylines = null}
3082
+ __init85() {this.polylines = null}
2997
3083
  /**
2998
3084
  * When `true`, the renderer draws a rounded-rect outline of the entity's
2999
3085
  * local bounds after painting the curves. Useful for drag feedback and
3000
3086
  * debugging hit areas. Defaults to `false`.
3001
3087
  */
3002
- __init79() {this.showBounds = false}
3088
+ __init86() {this.showBounds = false}
3003
3089
  constructor(doc, opts = {}) {
3004
- super();_class5.prototype.__init74.call(this);_class5.prototype.__init75.call(this);_class5.prototype.__init76.call(this);_class5.prototype.__init77.call(this);_class5.prototype.__init78.call(this);_class5.prototype.__init79.call(this);;
3090
+ super();_class5.prototype.__init81.call(this);_class5.prototype.__init82.call(this);_class5.prototype.__init83.call(this);_class5.prototype.__init84.call(this);_class5.prototype.__init85.call(this);_class5.prototype.__init86.call(this);;
3005
3091
  this.doc = doc;
3006
3092
  this.lineWidth = _nullishCoalesce(opts.lineWidth, () => ( 2));
3007
3093
  this.cache = _nullishCoalesce(opts.cache, () => ( true));
@@ -3376,9 +3462,9 @@ var Group = class extends _chunk2Z23LTH3js.Entity {
3376
3462
  // src/math/SpatialHashGrid.ts
3377
3463
  var SpatialHashGrid = (_class6 = class {
3378
3464
 
3379
- __init80() {this.grid = /* @__PURE__ */ new Map()}
3380
- __init81() {this.entityCells = /* @__PURE__ */ new Map()}
3381
- constructor(cellSize = 64) {;_class6.prototype.__init80.call(this);_class6.prototype.__init81.call(this);
3465
+ __init87() {this.grid = /* @__PURE__ */ new Map()}
3466
+ __init88() {this.entityCells = /* @__PURE__ */ new Map()}
3467
+ constructor(cellSize = 64) {;_class6.prototype.__init87.call(this);_class6.prototype.__init88.call(this);
3382
3468
  this.cellSize = cellSize;
3383
3469
  }
3384
3470
  hash(cx, cy) {
@@ -3470,18 +3556,18 @@ var SpatialHashGrid = (_class6 = class {
3470
3556
  // src/tree/DOMPortalEntity.ts
3471
3557
  var DOMPortalEntity = (_class7 = class extends _chunk2Z23LTH3js.Entity {
3472
3558
 
3473
- __init82() {this.isDOMPortal = true}
3474
- __init83() {this.domListeners = []}
3475
- __init84() {this.resizeObserver = null}
3476
- __init85() {this.cachedWidth = 100}
3477
- __init86() {this.cachedHeight = 100}
3478
- __init87() {this.lastWidth = ""}
3479
- __init88() {this.lastHeight = ""}
3480
- __init89() {this.lastTransform = ""}
3481
- __init90() {this.lastZIndex = ""}
3482
- __init91() {this.lastOpacity = ""}
3559
+ __init89() {this.isDOMPortal = true}
3560
+ __init90() {this.domListeners = []}
3561
+ __init91() {this.resizeObserver = null}
3562
+ __init92() {this.cachedWidth = 100}
3563
+ __init93() {this.cachedHeight = 100}
3564
+ __init94() {this.lastWidth = ""}
3565
+ __init95() {this.lastHeight = ""}
3566
+ __init96() {this.lastTransform = ""}
3567
+ __init97() {this.lastZIndex = ""}
3568
+ __init98() {this.lastOpacity = ""}
3483
3569
  constructor(domElement, width, height, id) {
3484
- super(id);_class7.prototype.__init82.call(this);_class7.prototype.__init83.call(this);_class7.prototype.__init84.call(this);_class7.prototype.__init85.call(this);_class7.prototype.__init86.call(this);_class7.prototype.__init87.call(this);_class7.prototype.__init88.call(this);_class7.prototype.__init89.call(this);_class7.prototype.__init90.call(this);_class7.prototype.__init91.call(this);;
3570
+ super(id);_class7.prototype.__init89.call(this);_class7.prototype.__init90.call(this);_class7.prototype.__init91.call(this);_class7.prototype.__init92.call(this);_class7.prototype.__init93.call(this);_class7.prototype.__init94.call(this);_class7.prototype.__init95.call(this);_class7.prototype.__init96.call(this);_class7.prototype.__init97.call(this);_class7.prototype.__init98.call(this);;
3485
3571
  this.domElement = domElement;
3486
3572
  this.width = _nullishCoalesce(width, () => ( 0));
3487
3573
  this.height = _nullishCoalesce(height, () => ( 0));
package/dist/index.mjs CHANGED
@@ -667,6 +667,19 @@ var Scene = class _Scene {
667
667
  dirty = true;
668
668
  /** Whether to throttle rendering to 2 FPS when the scene is static to save power. */
669
669
  autoThrottle = true;
670
+ // --- Frame telemetry (read via `frameStats`) ---------------------------
671
+ /** Wall-clock ms spent inside the last `render()` call. */
672
+ _lastFrameMs = 0;
673
+ /** Rolling exponential average of rendered-frame intervals, in ms. */
674
+ _avgFrameIntervalMs = 0;
675
+ /** dt (ms) handed to the last rendered frame. */
676
+ _lastDt = 0;
677
+ /** Count of frames actually rendered since the loop started. */
678
+ _renderedFrames = 0;
679
+ /** Count of rAF ticks skipped (idle / capped) since the loop started. */
680
+ _skippedFrames = 0;
681
+ /** `time` of the previous *rendered* frame, for interval measurement. */
682
+ _lastRenderTick = 0;
670
683
  /**
671
684
  * Frame-rate cap (power saving). `0` = uncapped (native refresh). When set,
672
685
  * the loop renders at most `maxFPS` times per second; animations still run,
@@ -707,6 +720,9 @@ var Scene = class _Scene {
707
720
  contentMetricScaleEpoch = -1;
708
721
  contentMetricScaleX = 1;
709
722
  contentProjectionEnabled = true;
723
+ // Virtualization margin (px) for content projection; `undefined` → one
724
+ // viewport height, resolved at sync time. `Infinity` = materialize everything.
725
+ contentProjectionMargin = void 0;
710
726
  /**
711
727
  * True while a text-selection drag that started on a projection's blank
712
728
  * region (no text node under the press) is being driven manually — the
@@ -857,6 +873,7 @@ var Scene = class _Scene {
857
873
  this.particleBackend = options.particleBackend ?? "auto";
858
874
  this.a11ySyncInterval = options.a11ySyncInterval ?? 0;
859
875
  this.contentProjectionEnabled = options.contentProjection ?? true;
876
+ this.contentProjectionMargin = options.contentProjectionMargin;
860
877
  this._devActive = _Scene._devModeDetected();
861
878
  this.reducedMotionQuery = typeof window !== "undefined" && typeof window.matchMedia === "function" ? window.matchMedia("(prefers-reduced-motion: reduce)") : null;
862
879
  this.root = new class RootEntity extends Entity {
@@ -1318,6 +1335,36 @@ var Scene = class _Scene {
1318
1335
  markDirty() {
1319
1336
  this.dirty = true;
1320
1337
  }
1338
+ /**
1339
+ * Live frame telemetry for profilers and devtools overlays. All timings are
1340
+ * measured on the `requestAnimationFrame` loop; a scene driven only by
1341
+ * {@link step} (e.g. deterministic video export) leaves these at their zero
1342
+ * defaults.
1343
+ *
1344
+ * `fps` is derived from the interval between *rendered* frames, so idle
1345
+ * `onDemand` scenes and frames skipped by the {@link maxFPS} cap or the
1346
+ * static auto-throttle do not deflate it — it reports the cadence of actual
1347
+ * redraws, not the raw rAF rate. `frameTimeMs` is the wall-clock cost of the
1348
+ * last `render()` pass alone (excludes a11y/content-projection sync).
1349
+ *
1350
+ * The renderer always repaints the full canvas, so there is no partial
1351
+ * dirty-rectangle to expose; `dirty` is the boolean redraw-pending flag and
1352
+ * `pendingRedraw` reflects whether the next `onDemand` tick will actually
1353
+ * render.
1354
+ */
1355
+ get frameStats() {
1356
+ const interval = this._avgFrameIntervalMs;
1357
+ return {
1358
+ fps: interval > 0 ? Math.min(1e3 / interval, this.maxFPS > 0 ? this.maxFPS : 1e3 / interval) : 0,
1359
+ frameTimeMs: this._lastFrameMs,
1360
+ frameIntervalMs: interval,
1361
+ dt: this._lastDt,
1362
+ renderedFrames: this._renderedFrames,
1363
+ skippedFrames: this._skippedFrames,
1364
+ renderMode: this.renderMode,
1365
+ dirty: this.dirty
1366
+ };
1367
+ }
1321
1368
  /** True when any node in the subtree has a pending animation. */
1322
1369
  /** True when any node in the subtree is interactive (drives a11y sync). */
1323
1370
  syncOptionalAttribute(element, name, value) {
@@ -1618,6 +1665,56 @@ var Scene = class _Scene {
1618
1665
  * hidden (`display: none`) so text-heavy scenes only materialize what is
1619
1666
  * visible to the browser's text machinery anyway.
1620
1667
  */
1668
+ /**
1669
+ * Whether `node`'s world-space box, expanded by `margin` px on every side,
1670
+ * overlaps the scene viewport AND every `clipChildren` ancestor's box. Used
1671
+ * both to virtualize content projection (materialize only near-viewport text,
1672
+ * at `margin = contentProjectionMargin`) and for the exact `display:none`
1673
+ * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
1674
+ * culling and always count as visible, matching the legacy behavior.
1675
+ */
1676
+ projectionBoxVisible(node, tf, margin) {
1677
+ if (!(node.width > 0 && node.height > 0)) return true;
1678
+ const { a, b, c, d, e, f } = tf;
1679
+ const worldCorners = [];
1680
+ let minX = Infinity;
1681
+ let minY = Infinity;
1682
+ let maxX = -Infinity;
1683
+ let maxY = -Infinity;
1684
+ for (let i = 0; i < 4; i++) {
1685
+ const lx = i & 1 ? node.width : 0;
1686
+ const ly = i & 2 ? node.height : 0;
1687
+ const wx = a * lx + c * ly + e;
1688
+ const wy = b * lx + d * ly + f;
1689
+ worldCorners.push({ x: wx, y: wy });
1690
+ if (wx < minX) minX = wx;
1691
+ if (wx > maxX) maxX = wx;
1692
+ if (wy < minY) minY = wy;
1693
+ if (wy > maxY) maxY = wy;
1694
+ }
1695
+ if (!(maxX >= -margin && minX <= this.width + margin && maxY >= -margin && minY <= this.height + margin)) {
1696
+ return false;
1697
+ }
1698
+ for (let ancestor = node.parent; ancestor; ancestor = ancestor.parent) {
1699
+ if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
1700
+ let localMinX = Infinity;
1701
+ let localMinY = Infinity;
1702
+ let localMaxX = -Infinity;
1703
+ let localMaxY = -Infinity;
1704
+ for (const corner of worldCorners) {
1705
+ const local = ancestor.worldToLocal(corner.x, corner.y);
1706
+ if (!local) continue;
1707
+ localMinX = Math.min(localMinX, local.x);
1708
+ localMinY = Math.min(localMinY, local.y);
1709
+ localMaxX = Math.max(localMaxX, local.x);
1710
+ localMaxY = Math.max(localMaxY, local.y);
1711
+ }
1712
+ if (!(localMaxX >= -margin && localMinX <= ancestor.width + margin && localMaxY >= -margin && localMinY <= ancestor.height + margin)) {
1713
+ return false;
1714
+ }
1715
+ }
1716
+ return true;
1717
+ }
1621
1718
  syncContentProjection(node) {
1622
1719
  if (!this.contentProjectionEnabled || !this.a11yRoot) return;
1623
1720
  const projection = node.getContentProjection();
@@ -1631,6 +1728,17 @@ var Scene = class _Scene {
1631
1728
  }
1632
1729
  return;
1633
1730
  }
1731
+ const worldTf = node.getWorldTransform();
1732
+ const margin = this.contentProjectionMargin ?? this.height;
1733
+ if (Number.isFinite(margin) && !this.projectionBoxVisible(node, worldTf, margin)) {
1734
+ if (el) {
1735
+ this.clearContentGridState(node.id, el);
1736
+ el.remove();
1737
+ this.contentElements.delete(node.id);
1738
+ this.a11yNeedsReorder = true;
1739
+ }
1740
+ return;
1741
+ }
1634
1742
  if (!el) {
1635
1743
  el = document.createElement("div");
1636
1744
  el.setAttribute("data-vecto-content", node.id);
@@ -1727,7 +1835,7 @@ var Scene = class _Scene {
1727
1835
  el.style.userSelect = selectable ? "text" : "none";
1728
1836
  el.style.cursor = selectable ? "text" : "";
1729
1837
  }
1730
- const { a, b, c, d, e, f } = node.getWorldTransform();
1838
+ const { a, b, c, d, e, f } = worldTf;
1731
1839
  const contentX = projection.contentX ?? 0;
1732
1840
  const contentY = projection.contentY ?? 0;
1733
1841
  const baselineOffset = lines && lines.length > 0 ? 0 : projection.baseline === void 0 ? 0 : projection.baseline - cssLineBoxBaseline(font, projection.lineHeight ?? 16);
@@ -1737,42 +1845,7 @@ var Scene = class _Scene {
1737
1845
  if (node.width > 0) el.style.width = `${node.width}px`;
1738
1846
  if (node.height > 0) el.style.height = `${node.height}px`;
1739
1847
  el.style.transform = `matrix(${a}, ${b}, ${c}, ${d}, 0, 0)`;
1740
- let visible = true;
1741
- if (node.width > 0 && node.height > 0) {
1742
- const worldCorners = [];
1743
- let minX = Infinity;
1744
- let minY = Infinity;
1745
- let maxX = -Infinity;
1746
- let maxY = -Infinity;
1747
- for (let i = 0; i < 4; i++) {
1748
- const lx = i & 1 ? node.width : 0;
1749
- const ly = i & 2 ? node.height : 0;
1750
- const wx = a * lx + c * ly + e;
1751
- const wy = b * lx + d * ly + f;
1752
- worldCorners.push({ x: wx, y: wy });
1753
- if (wx < minX) minX = wx;
1754
- if (wx > maxX) maxX = wx;
1755
- if (wy < minY) minY = wy;
1756
- if (wy > maxY) maxY = wy;
1757
- }
1758
- visible = maxX >= 0 && minX <= this.width && maxY >= 0 && minY <= this.height;
1759
- for (let ancestor = node.parent; visible && ancestor; ancestor = ancestor.parent) {
1760
- if (!ancestor.clipChildren || ancestor.width <= 0 || ancestor.height <= 0) continue;
1761
- let localMinX = Infinity;
1762
- let localMinY = Infinity;
1763
- let localMaxX = -Infinity;
1764
- let localMaxY = -Infinity;
1765
- for (const corner of worldCorners) {
1766
- const local = ancestor.worldToLocal(corner.x, corner.y);
1767
- if (!local) continue;
1768
- localMinX = Math.min(localMinX, local.x);
1769
- localMinY = Math.min(localMinY, local.y);
1770
- localMaxX = Math.max(localMaxX, local.x);
1771
- localMaxY = Math.max(localMaxY, local.y);
1772
- }
1773
- visible = localMaxX >= 0 && localMinX <= ancestor.width && localMaxY >= 0 && localMinY <= ancestor.height;
1774
- }
1775
- }
1848
+ const visible = this.projectionBoxVisible(node, worldTf, 0);
1776
1849
  const display = visible ? "" : "none";
1777
1850
  if (el.style.display !== display) el.style.display = display;
1778
1851
  }
@@ -2221,6 +2294,7 @@ var Scene = class _Scene {
2221
2294
  cap = Math.min(cap, 2);
2222
2295
  }
2223
2296
  if (cap > 0 && time - this.lastTime < 1e3 / cap - 1) {
2297
+ this._skippedFrames++;
2224
2298
  this.scheduleFrame();
2225
2299
  return;
2226
2300
  }
@@ -2231,11 +2305,23 @@ var Scene = class _Scene {
2231
2305
  }
2232
2306
  this.lastTime = time;
2233
2307
  if (this.renderMode === "onDemand" && isIdle) {
2308
+ this._skippedFrames++;
2234
2309
  this.scheduleFrame();
2235
2310
  return;
2236
2311
  }
2237
2312
  this.dirty = false;
2313
+ const now = typeof performance !== "undefined" ? performance.now() : time;
2314
+ if (this._lastRenderTick > 0) {
2315
+ const interval = time - this._lastRenderTick;
2316
+ if (interval > 0) {
2317
+ this._avgFrameIntervalMs = this._avgFrameIntervalMs === 0 ? interval : this._avgFrameIntervalMs * 0.9 + interval * 0.1;
2318
+ }
2319
+ }
2320
+ this._lastRenderTick = time;
2321
+ this._lastDt = dt;
2238
2322
  this.render(this.renderer, dt, time);
2323
+ this._lastFrameMs = (typeof performance !== "undefined" ? performance.now() : time) - now;
2324
+ this._renderedFrames++;
2239
2325
  const hasActiveAnimation = this.frameHadAnimation;
2240
2326
  const hasInteractive = this.frameHadInteractive;
2241
2327
  const wantsContentSync = this.contentProjectionEnabled;
@@ -98,9 +98,43 @@ export interface SceneOptions {
98
98
  * scenes to skip the sync walk.
99
99
  */
100
100
  contentProjection?: boolean;
101
+ /**
102
+ * How far outside the viewport (in CSS px, each side) content projections are
103
+ * materialized as DOM. Projections whose box is farther than this are not
104
+ * created — and are removed when they scroll past it — so a document taller
105
+ * than the viewport keeps only a bounded, near-viewport set of DOM nodes
106
+ * instead of one element (plus a `<span>` per line) per block for the whole
107
+ * document. A larger margin keeps more off-screen text ready for native
108
+ * find-in-page / selection at the cost of more DOM; `Infinity` restores the
109
+ * legacy "materialize the entire document" behavior. Default: one viewport
110
+ * height (`undefined` → resolved to `Scene.height` at sync time).
111
+ */
112
+ contentProjectionMargin?: number;
101
113
  }
102
114
  /** Frame-rate the loop is capped to when the OS requests reduced motion. */
103
115
  export declare const REDUCED_MOTION_FPS = 30;
116
+ /**
117
+ * Live render-loop telemetry, read from {@link Scene.frameStats}. See that
118
+ * getter for how each field is measured.
119
+ */
120
+ export interface FrameStats {
121
+ /** Rendered-frame cadence (Hz), clamped to `maxFPS`. `0` before the first pair of rendered frames. */
122
+ fps: number;
123
+ /** Wall-clock ms of the last `render()` pass (excludes a11y/content sync). */
124
+ frameTimeMs: number;
125
+ /** Smoothed interval between rendered frames, in ms (EMA). */
126
+ frameIntervalMs: number;
127
+ /** dt (ms) handed to the last rendered frame. */
128
+ dt: number;
129
+ /** Total frames rendered since `start()`. */
130
+ renderedFrames: number;
131
+ /** Total rAF ticks skipped (idle/onDemand/capped) since `start()`. */
132
+ skippedFrames: number;
133
+ /** The scene's current render mode. */
134
+ renderMode: 'always' | 'onDemand';
135
+ /** Whether a redraw is currently pending (the boolean dirty flag). */
136
+ dirty: boolean;
137
+ }
104
138
  export interface A11yTreeNode {
105
139
  id: string;
106
140
  tag: string;
@@ -147,6 +181,18 @@ export declare class Scene {
147
181
  private dirty;
148
182
  /** Whether to throttle rendering to 2 FPS when the scene is static to save power. */
149
183
  autoThrottle: boolean;
184
+ /** Wall-clock ms spent inside the last `render()` call. */
185
+ private _lastFrameMs;
186
+ /** Rolling exponential average of rendered-frame intervals, in ms. */
187
+ private _avgFrameIntervalMs;
188
+ /** dt (ms) handed to the last rendered frame. */
189
+ private _lastDt;
190
+ /** Count of frames actually rendered since the loop started. */
191
+ private _renderedFrames;
192
+ /** Count of rAF ticks skipped (idle / capped) since the loop started. */
193
+ private _skippedFrames;
194
+ /** `time` of the previous *rendered* frame, for interval measurement. */
195
+ private _lastRenderTick;
150
196
  /**
151
197
  * Frame-rate cap (power saving). `0` = uncapped (native refresh). When set,
152
198
  * the loop renders at most `maxFPS` times per second; animations still run,
@@ -182,6 +228,7 @@ export declare class Scene {
182
228
  private contentMetricScaleEpoch;
183
229
  private contentMetricScaleX;
184
230
  private contentProjectionEnabled;
231
+ private contentProjectionMargin;
185
232
  /**
186
233
  * True while a text-selection drag that started on a projection's blank
187
234
  * region (no text node under the press) is being driven manually — the
@@ -339,6 +386,24 @@ export declare class Scene {
339
386
  * entity state outside of {@link Entity.animate} so the change is rendered.
340
387
  */
341
388
  markDirty(): void;
389
+ /**
390
+ * Live frame telemetry for profilers and devtools overlays. All timings are
391
+ * measured on the `requestAnimationFrame` loop; a scene driven only by
392
+ * {@link step} (e.g. deterministic video export) leaves these at their zero
393
+ * defaults.
394
+ *
395
+ * `fps` is derived from the interval between *rendered* frames, so idle
396
+ * `onDemand` scenes and frames skipped by the {@link maxFPS} cap or the
397
+ * static auto-throttle do not deflate it — it reports the cadence of actual
398
+ * redraws, not the raw rAF rate. `frameTimeMs` is the wall-clock cost of the
399
+ * last `render()` pass alone (excludes a11y/content-projection sync).
400
+ *
401
+ * The renderer always repaints the full canvas, so there is no partial
402
+ * dirty-rectangle to expose; `dirty` is the boolean redraw-pending flag and
403
+ * `pendingRedraw` reflects whether the next `onDemand` tick will actually
404
+ * render.
405
+ */
406
+ get frameStats(): FrameStats;
342
407
  /** True when any node in the subtree has a pending animation. */
343
408
  /** True when any node in the subtree is interactive (drives a11y sync). */
344
409
  private syncOptionalAttribute;
@@ -350,6 +415,15 @@ export declare class Scene {
350
415
  * hidden (`display: none`) so text-heavy scenes only materialize what is
351
416
  * visible to the browser's text machinery anyway.
352
417
  */
418
+ /**
419
+ * Whether `node`'s world-space box, expanded by `margin` px on every side,
420
+ * overlaps the scene viewport AND every `clipChildren` ancestor's box. Used
421
+ * both to virtualize content projection (materialize only near-viewport text,
422
+ * at `margin = contentProjectionMargin`) and for the exact `display:none`
423
+ * visibility test (`margin = 0`). Boundless nodes (width/height 0) opt out of
424
+ * culling and always count as visible, matching the legacy behavior.
425
+ */
426
+ private projectionBoxVisible;
353
427
  private syncContentProjection;
354
428
  /**
355
429
  * Materialize a prepared grid in logical source order while positioning each
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectojs/core",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },