@vectojs/core 1.15.0 → 1.16.1

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.
@@ -146,8 +146,42 @@ var Entity = class {
146
146
  // Danmaku hot loop), so a bare `entity.x = v` is one boolean check + field write.
147
147
  _hasTransitions = false;
148
148
  _transitions = null;
149
- _drivers = /* @__PURE__ */ new Map();
149
+ // Lazily allocated: null until first use. A scene of many passive entities
150
+ // (particles, data points) never touches these, so paying an empty-Map/array
151
+ // allocation per entity in the constructor is pure waste at scale.
152
+ _drivers = null;
150
153
  _mounted = false;
154
+ _destroyed = false;
155
+ // Frame this entity's active drivers were last advanced by Scene's batched
156
+ // WASM animation pass (see Scene._tickBatchedDrivers), or -1 if never. When
157
+ // it equals the current frame, tickDrivers() must skip its own tick loop —
158
+ // otherwise a driver already advanced by the batch pass would be ticked a
159
+ // second time by the normal per-entity update() walk in the same frame.
160
+ // Irrelevant (and harmless) for entities never touched by WASM batching.
161
+ _driversTickedFrame = -1;
162
+ // Cached cos/sin, recomputed only when rotation actually changes. renderNode
163
+ // and getWorldTransform() both read this instead of calling Math.cos/sin per
164
+ // entity per frame (V8's are ~2.5x slower than other engines).
165
+ _trig = { cos: 1, sin: 0 };
166
+ _trigRotation = Number.NaN;
167
+ // NaN !== any rotation -> first read computes
168
+ // Per-frame world-matrix cache. Written by Scene during the render walk;
169
+ // getWorldTransform() returns it only while `_worldFrame === scene.currentFrame`
170
+ // and otherwise falls back to the full ancestor walk, so it can never return a
171
+ // stale/wrong transform — only sometimes miss the fast path.
172
+ _wa = 1;
173
+ _wb = 0;
174
+ _wc = 0;
175
+ _wd = 1;
176
+ _we = 0;
177
+ _wf = 0;
178
+ _worldFrame = -1;
179
+ // Slot in the Scene's resident WASM transform store, or -1 when this entity is
180
+ // not in that store (JS transform path, overlay/detached, or before the first
181
+ // structural rebuild). Assigned by Scene on a structural rebuild; the Scene
182
+ // validates it against its slot table before trusting it, so a stale value can
183
+ // only cost a JS-path fallback, never a wrong read.
184
+ _storeSlot = -1;
151
185
  get x() {
152
186
  return this._x;
153
187
  }
@@ -224,10 +258,12 @@ var Entity = class {
224
258
  * fixed viewport. Off by default (children render unclipped). Canvas2D only.
225
259
  */
226
260
  clipChildren = false;
227
- listeners = /* @__PURE__ */ new Map();
261
+ // Lazily allocated (see _drivers above). Most entities never register a
262
+ // listener or an imperative animate() tween.
263
+ listeners = null;
228
264
  /** Capture-phase listeners (fired root→target before bubble). */
229
- captureListeners = /* @__PURE__ */ new Map();
230
- animations = [];
265
+ captureListeners = null;
266
+ animations = null;
231
267
  constructor(id) {
232
268
  this.id = id || `entity_${Math.random().toString(36).substring(2, 9)}`;
233
269
  }
@@ -262,6 +298,7 @@ var Entity = class {
262
298
  const s = this.scene;
263
299
  if (s) {
264
300
  s.a11yNeedsReorder = true;
301
+ s.markStructureChanged?.();
265
302
  s.markDirty();
266
303
  child._notifyMounted();
267
304
  }
@@ -291,6 +328,7 @@ var Entity = class {
291
328
  if (s) {
292
329
  s.detachA11y(child);
293
330
  s.a11yNeedsReorder = true;
331
+ s.markStructureChanged?.();
294
332
  s.markDirty();
295
333
  }
296
334
  }
@@ -339,7 +377,7 @@ var Entity = class {
339
377
  * @example entity.animate({ x: 400, opacity: 0 }, 500);
340
378
  */
341
379
  animate(targetProps, durationMs) {
342
- this.animations.push({
380
+ (this.animations ??= []).push({
343
381
  target: targetProps,
344
382
  duration: durationMs,
345
383
  startTime: -1,
@@ -392,9 +430,9 @@ var Entity = class {
392
430
  * that need to seed a starting state (e.g. the presence helper's enter `from`).
393
431
  */
394
432
  setImmediate(prop, v) {
395
- const existing = this._drivers.get(prop);
433
+ const existing = this._drivers?.get(prop);
396
434
  if (existing) this._settleDriver(existing);
397
- this._drivers.delete(prop);
435
+ this._drivers?.delete(prop);
398
436
  this._applyAnimated(prop, v);
399
437
  }
400
438
  _settleDriver(driver) {
@@ -405,13 +443,13 @@ var Entity = class {
405
443
  }
406
444
  _spawnDriver(prop, to, cfg) {
407
445
  if (prop !== "opacity" && this.scene?.prefersReducedMotion) {
408
- const existing2 = this._drivers.get(prop);
446
+ const existing2 = this._drivers?.get(prop);
409
447
  if (existing2) this._settleDriver(existing2);
410
- this._drivers.delete(prop);
448
+ this._drivers?.delete(prop);
411
449
  this._applyAnimated(prop, to);
412
450
  return;
413
451
  }
414
- const existing = this._drivers.get(prop);
452
+ const existing = this._drivers?.get(prop);
415
453
  if (existing) {
416
454
  this._settleDriver(existing);
417
455
  existing.retarget(to);
@@ -419,8 +457,9 @@ var Entity = class {
419
457
  }
420
458
  const from = this._currentOf(prop);
421
459
  const driver = isTweenConfig(cfg) ? new TweenDriver(from, to, cfg) : new SpringDriver(from, to, cfg === "spring" ? {} : cfg);
422
- this._drivers.set(prop, driver);
460
+ (this._drivers ??= /* @__PURE__ */ new Map()).set(prop, driver);
423
461
  this.scene?.markDirty();
462
+ this.scene?._registerActiveDriverEntity(this);
424
463
  }
425
464
  /** Assignment path when a declarative transition is configured for `prop`. */
426
465
  _animateProp(prop, to) {
@@ -453,7 +492,7 @@ var Entity = class {
453
492
  entries.map(
454
493
  (e) => new Promise((resolve) => {
455
494
  this._spawnDriver(e[0], e[1], cfg);
456
- const d = this._drivers.get(e[0]);
495
+ const d = this._drivers?.get(e[0]);
457
496
  if (!d)
458
497
  resolve();
459
498
  else d.onDone = resolve;
@@ -463,7 +502,8 @@ var Entity = class {
463
502
  }
464
503
  /** Advance active property drivers one frame. Call from update(). */
465
504
  tickDrivers(dt) {
466
- if (this._drivers.size === 0) return;
505
+ if (!this._drivers || this._drivers.size === 0) return;
506
+ if (this.scene && this._driversTickedFrame === this.scene.currentFrame) return;
467
507
  for (const [prop, driver] of this._drivers) {
468
508
  driver.tick(dt);
469
509
  if (driver.isDone()) {
@@ -476,6 +516,38 @@ var Entity = class {
476
516
  }
477
517
  this.scene?.markDirty();
478
518
  }
519
+ /**
520
+ * Internal: this entity's active-driver map (read-only view), or `null` if
521
+ * it has none. Called only by Scene's batched WASM animation pass, never
522
+ * application code. Returns the Map directly (not a callback iteration) so
523
+ * a caller can `for...of` it with zero per-entity closure allocation — the
524
+ * integrated benchmark (benchmarks/anim-wasm-scene) found a fresh callback
525
+ * per entity per frame was a real cost, not a negligible one.
526
+ */
527
+ _driverEntries() {
528
+ return this._drivers;
529
+ }
530
+ /**
531
+ * Internal: finalize one driver that was ALREADY advanced externally this
532
+ * frame (e.g. by Scene's batched WASM tick via `driver.syncExternal`, or a
533
+ * direct `driver.tick()` call for a driver the batch can't offload) —
534
+ * exactly mirrors tickDrivers()'s own per-driver completion logic, so a
535
+ * driver behaves identically regardless of which path ticked it. Called
536
+ * only by Scene, never application code.
537
+ */
538
+ _applyDriverTick(prop, driver) {
539
+ if (driver.isDone()) {
540
+ this._applyAnimated(prop, driver.target);
541
+ this._settleDriver(driver);
542
+ this._drivers?.delete(prop);
543
+ } else {
544
+ this._applyAnimated(prop, driver.value);
545
+ }
546
+ }
547
+ /** Internal: true if this entity currently has any active property driver. */
548
+ _hasActiveDrivers() {
549
+ return !!this._drivers && this._drivers.size > 0;
550
+ }
479
551
  /**
480
552
  * Advance the entity's internal state for one frame.
481
553
  *
@@ -487,7 +559,7 @@ var Entity = class {
487
559
  */
488
560
  update(dt, time) {
489
561
  this.tickDrivers(dt);
490
- if (this.animations.length > 0) {
562
+ if (this.animations && this.animations.length > 0) {
491
563
  const anim = this.animations[0];
492
564
  if (anim.startTime === -1) {
493
565
  anim.startTime = time;
@@ -528,7 +600,7 @@ var Entity = class {
528
600
  * @example entity.on('click', (e) => console.log('clicked', e));
529
601
  */
530
602
  on(event, callback, options) {
531
- const map = options?.capture ? this.captureListeners : this.listeners;
603
+ const map = options?.capture ? this.captureListeners ??= /* @__PURE__ */ new Map() : this.listeners ??= /* @__PURE__ */ new Map();
532
604
  if (!map.has(event)) {
533
605
  map.set(event, []);
534
606
  }
@@ -544,7 +616,7 @@ var Entity = class {
544
616
  * @returns `this` for method chaining.
545
617
  */
546
618
  off(event, callback, options) {
547
- const handlers = (options?.capture ? this.captureListeners : this.listeners).get(event);
619
+ const handlers = (options?.capture ? this.captureListeners : this.listeners)?.get(event);
548
620
  if (handlers) {
549
621
  const idx = handlers.indexOf(callback);
550
622
  if (idx !== -1) handlers.splice(idx, 1);
@@ -552,17 +624,37 @@ var Entity = class {
552
624
  return this;
553
625
  }
554
626
  /**
555
- * Tear down this entity: clear all animations, event listeners, and detach
556
- * from parent. Call before discarding an entity to prevent memory leaks.
627
+ * Tear down this entity **and its entire subtree**: recursively destroy every
628
+ * descendant (leaf-first), clear all animations, event listeners, and property
629
+ * drivers, then detach from the parent. Call before discarding an entity to
630
+ * prevent memory leaks.
631
+ *
632
+ * Recursing here is what frees a subtree's GPU buffers, layout workers, and DOM
633
+ * observers when an app does `entity.destroy()` or `scene.remove(subtree)` on a
634
+ * route change — without it, only the root's own state was released and every
635
+ * descendant (and its subclass resources) was stranded. Subclasses that own
636
+ * external resources override `destroy()`, free their resource, then call
637
+ * `super.destroy()`; because children don't depend on a parent's resource, the
638
+ * order (parent resource first, then descendants) is safe.
639
+ *
640
+ * Idempotent and re-entrancy safe: a second call is a no-op, and because each
641
+ * child is detached as it is destroyed, destroying a subtree never double-frees.
557
642
  */
558
643
  destroy() {
559
- this.animations = [];
560
- for (const driver of this._drivers.values()) {
561
- this._settleDriver(driver);
644
+ if (this._destroyed) return;
645
+ this._destroyed = true;
646
+ while (this.children.length > 0) {
647
+ this.children.at(-1).destroy();
648
+ }
649
+ this.animations = null;
650
+ if (this._drivers) {
651
+ for (const driver of this._drivers.values()) {
652
+ this._settleDriver(driver);
653
+ }
654
+ this._drivers.clear();
562
655
  }
563
- this._drivers.clear();
564
- this.listeners.clear();
565
- this.captureListeners.clear();
656
+ this.listeners?.clear();
657
+ this.captureListeners?.clear();
566
658
  if (this.parent) {
567
659
  this.parent.remove(this);
568
660
  }
@@ -577,7 +669,7 @@ var Entity = class {
577
669
  * @param payload - Arbitrary data forwarded to each listener.
578
670
  */
579
671
  emit(event, payload) {
580
- const handlers = this.listeners.get(event);
672
+ const handlers = this.listeners?.get(event);
581
673
  if (handlers) {
582
674
  handlers.forEach((h) => h(payload));
583
675
  }
@@ -603,7 +695,7 @@ var Entity = class {
603
695
  }
604
696
  /** Run one node's listeners for the event, honoring stopImmediatePropagation. */
605
697
  fireListeners(node, map, event) {
606
- const handlers = map.get(event.type);
698
+ const handlers = map?.get(event.type);
607
699
  if (!handlers) return;
608
700
  event.currentTarget = node;
609
701
  for (const h of handlers.slice()) {
@@ -642,10 +734,45 @@ var Entity = class {
642
734
  getGlobalPosition() {
643
735
  return this.localToWorld(0, 0);
644
736
  }
737
+ /**
738
+ * Internal: read this entity's cached world matrix into `out` — the SAME
739
+ * cache {@link getWorldTransform} reads — without allocating a wrapper
740
+ * object. Returns `false` (leaving `out` untouched) when the cache isn't
741
+ * valid for `frame` (typically the caller's `scene.currentFrame`), exactly
742
+ * mirroring {@link getWorldTransform}'s own validity check; the caller
743
+ * falls back to {@link getWorldTransform}'s full walk in that case. Exists
744
+ * so a per-entity gather that runs every entity through this (e.g. G3's
745
+ * `gatherHitAABBs`) pays for six scalar reads instead of one object
746
+ * allocation per entity per call — the exact class of per-frame garbage
747
+ * the G2 integrated benchmark found dominating its own gather cost.
748
+ */
749
+ _readWorldCache(frame, out) {
750
+ if (this._worldFrame < 0 || this._worldFrame !== frame) return false;
751
+ out.a = this._wa;
752
+ out.b = this._wb;
753
+ out.c = this._wc;
754
+ out.d = this._wd;
755
+ out.e = this._we;
756
+ out.f = this._wf;
757
+ return true;
758
+ }
645
759
  /**
646
760
  * Return the exact accumulated Canvas `T * S * R` transform for this entity.
647
761
  */
648
762
  getWorldTransform() {
763
+ if (this._worldFrame >= 0) {
764
+ const s = this.scene;
765
+ if (s && this._worldFrame === s.currentFrame) {
766
+ return {
767
+ a: this._wa,
768
+ b: this._wb,
769
+ c: this._wc,
770
+ d: this._wd,
771
+ e: this._we,
772
+ f: this._wf
773
+ };
774
+ }
775
+ }
649
776
  const path = [this];
650
777
  let ancestor = this.parent;
651
778
  while (ancestor) {
@@ -660,8 +787,9 @@ var Entity = class {
660
787
  let f = 0;
661
788
  for (let i = path.length - 1; i >= 0; i--) {
662
789
  const node = path[i];
663
- const cos = Math.cos(node.rotation);
664
- const sin = Math.sin(node.rotation);
790
+ const trig = node._getTrig();
791
+ const cos = trig.cos;
792
+ const sin = trig.sin;
665
793
  const la = node.scaleX * cos;
666
794
  const lb = node.scaleY * sin;
667
795
  const lc = -node.scaleX * sin;
@@ -683,6 +811,37 @@ var Entity = class {
683
811
  }
684
812
  return { a, b, c, d, e, f };
685
813
  }
814
+ /**
815
+ * Return this entity's cached `{ cos, sin }` of its current rotation,
816
+ * recomputing only when `rotation` has actually changed since the last call.
817
+ * The same object identity is returned across calls with an unchanged
818
+ * rotation, so callers must treat it as read-only. Used by the render walk
819
+ * and {@link getWorldTransform} to avoid V8's comparatively slow Math.cos/sin
820
+ * (a software libm call, ~2.5x slower than other engines) per entity/frame.
821
+ */
822
+ _getTrig() {
823
+ if (this._trigRotation !== this._rotation) {
824
+ this._trig.cos = Math.cos(this._rotation);
825
+ this._trig.sin = Math.sin(this._rotation);
826
+ this._trigRotation = this._rotation;
827
+ }
828
+ return this._trig;
829
+ }
830
+ /**
831
+ * Store the world matrix Scene computed for this entity during the render
832
+ * walk, stamped with the frame it belongs to. {@link getWorldTransform}
833
+ * returns it verbatim while `frame === scene.currentFrame`. Internal: called
834
+ * only by Scene's renderer, never by application code.
835
+ */
836
+ _setWorldCache(a, b, c, d, e, f, frame) {
837
+ this._wa = a;
838
+ this._wb = b;
839
+ this._wc = c;
840
+ this._wd = d;
841
+ this._we = e;
842
+ this._wf = f;
843
+ this._worldFrame = frame;
844
+ }
686
845
  /** Convert a point from this entity's local space to Scene/world space. */
687
846
  localToWorld(localX, localY) {
688
847
  const { a, b, c, d, e, f } = this.getWorldTransform();
@@ -712,7 +871,12 @@ var Entity = class {
712
871
  * does not provide a render-specific box.
713
872
  */
714
873
  getWorldBounds() {
715
- const bounds = this.getBounds() ?? { x: 0, y: 0, width: this.width, height: this.height };
874
+ const bounds = this.getBounds() ?? {
875
+ x: 0,
876
+ y: 0,
877
+ width: this.width,
878
+ height: this.height
879
+ };
716
880
  const { a, b, c, d, e, f } = this.getWorldTransform();
717
881
  let minX = Infinity;
718
882
  let minY = Infinity;
@@ -853,7 +1017,7 @@ var Entity = class {
853
1017
  * @returns `true` if at least one animation or property driver remains.
854
1018
  */
855
1019
  hasPendingAnimations() {
856
- return this.animations.length > 0 || this._drivers.size > 0;
1020
+ return (this.animations?.length ?? 0) > 0 || (this._drivers?.size ?? 0) > 0;
857
1021
  }
858
1022
  };
859
1023
 
@@ -869,6 +1033,14 @@ var MSDFTextEntity = class extends Entity {
869
1033
  lineHeight;
870
1034
  maxWidth;
871
1035
  maxHeight;
1036
+ textAlign;
1037
+ // Optional hyphenator. Runs on the MAIN thread (a function can't be
1038
+ // structure-cloned into the layout worker), turning each word into parts
1039
+ // joined by soft hyphens (U+00AD); the worker then treats those as break
1040
+ // opportunities. `text` keeps the original string for a11y/content
1041
+ // projection; `layoutText` is the soft-hyphen-annotated string sent to layout.
1042
+ hyphenator = null;
1043
+ layoutText = "";
872
1044
  text = "";
873
1045
  lastRenderedSeqId = 0;
874
1046
  rgbColorCache = /* @__PURE__ */ new Map();
@@ -885,6 +1057,7 @@ var MSDFTextEntity = class extends Entity {
885
1057
  this.lineHeight = options.lineHeight;
886
1058
  this.maxWidth = options.maxWidth ?? 1e3;
887
1059
  this.maxHeight = options.maxHeight ?? 1e3;
1060
+ this.textAlign = options.textAlign ?? "left";
888
1061
  this.setText(text);
889
1062
  }
890
1063
  /** Change the wrap boundary and re-run layout for the current text. */
@@ -893,13 +1066,53 @@ var MSDFTextEntity = class extends Entity {
893
1066
  this.maxWidth = maxWidth;
894
1067
  this.queueLayout();
895
1068
  }
1069
+ /**
1070
+ * Set horizontal alignment (`'justify'` stretches wrapped lines flush to
1071
+ * {@link setMaxWidth}'s width; the last line stays ragged) and re-run layout.
1072
+ */
1073
+ setTextAlign(align) {
1074
+ if (this.textAlign === align) return;
1075
+ this.textAlign = align;
1076
+ this.queueLayout();
1077
+ }
1078
+ /**
1079
+ * Plug a hyphenator (word → parts). Break opportunities are inserted as soft
1080
+ * hyphens (U+00AD) into the string sent to layout, so a word that doesn't fit
1081
+ * can break with a visible hyphen. Soft hyphens already present in the text
1082
+ * work without one. Pass `null` to disable. The original text is preserved
1083
+ * for accessibility — only the layout string carries the hyphens.
1084
+ */
1085
+ setHyphenator(fn) {
1086
+ this.hyphenator = fn;
1087
+ this.rebuildLayoutText();
1088
+ this.queueLayout();
1089
+ }
896
1090
  setText(text) {
897
1091
  if (this.text === text && this.layoutResult) return;
898
1092
  this.text = text;
1093
+ this.rebuildLayoutText();
899
1094
  this.queueLayout();
900
1095
  }
1096
+ /**
1097
+ * Recompute {@link layoutText} from {@link text}: with a hyphenator active,
1098
+ * split each whitespace-delimited word and rejoin its parts with U+00AD so
1099
+ * the worker sees the break opportunities. Without one, the layout string is
1100
+ * the text unchanged.
1101
+ */
1102
+ rebuildLayoutText() {
1103
+ if (!this.hyphenator) {
1104
+ this.layoutText = this.text;
1105
+ return;
1106
+ }
1107
+ const SHY = "\xAD";
1108
+ this.layoutText = this.text.replace(/[^\s]+/g, (word) => {
1109
+ if (word.length <= 3 || word.includes(SHY)) return word;
1110
+ const parts = this.hyphenator(word);
1111
+ return parts.length > 1 ? parts.join(SHY) : word;
1112
+ });
1113
+ }
901
1114
  queueLayout() {
902
- LayoutWorkerManager.getInstance().queueLayout(this.id, this.text, {
1115
+ LayoutWorkerManager.getInstance().queueLayout(this.id, this.layoutText, {
903
1116
  fontId: this.font.id,
904
1117
  fontSize: this.fontSize,
905
1118
  maxWidth: this.maxWidth,
@@ -907,6 +1120,7 @@ var MSDFTextEntity = class extends Entity {
907
1120
  fontData: this.font.data,
908
1121
  letterSpacing: this.letterSpacing,
909
1122
  lineHeight: this.lineHeight,
1123
+ textAlign: this.textAlign,
910
1124
  callback: (res) => {
911
1125
  if (res.seqId < this.lastRenderedSeqId) return;
912
1126
  this.lastRenderedSeqId = res.seqId;
@@ -1016,7 +1230,7 @@ var MSDFTextEntity = class extends Entity {
1016
1230
  }
1017
1231
  }
1018
1232
  destroy() {
1019
- LayoutWorkerManager.getInstance().cancelLayout(this.id);
1233
+ LayoutWorkerManager.cancelLayoutForEntity(this.id);
1020
1234
  super.destroy();
1021
1235
  }
1022
1236
  };