@jamesyong42/infinite-canvas 1.3.0 → 1.6.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.
Files changed (37) hide show
  1. package/dist/advanced.cjs +2 -2
  2. package/dist/advanced.d.cts +2 -2
  3. package/dist/advanced.d.mts +2 -2
  4. package/dist/advanced.mjs +2 -2
  5. package/dist/devtools.cjs +3 -6
  6. package/dist/devtools.cjs.map +1 -1
  7. package/dist/devtools.d.cts +1 -1
  8. package/dist/devtools.d.mts +1 -1
  9. package/dist/devtools.mjs +2 -5
  10. package/dist/devtools.mjs.map +1 -1
  11. package/dist/{ecs-B4QrqfvQ.cjs → ecs-BtX_rCS3.cjs} +57 -2
  12. package/dist/ecs-BtX_rCS3.cjs.map +1 -0
  13. package/dist/{ecs-3kimUV5Z.mjs → ecs-O6AR7iFp.mjs} +33 -2
  14. package/dist/ecs-O6AR7iFp.mjs.map +1 -0
  15. package/dist/{hooks-CtP02JNt.cjs → hooks-B-UPFgGj.cjs} +2 -5
  16. package/dist/{hooks-CtP02JNt.cjs.map → hooks-B-UPFgGj.cjs.map} +1 -1
  17. package/dist/{hooks-gsQDDE56.mjs → hooks-Cpu0rEMv.mjs} +2 -5
  18. package/dist/{hooks-gsQDDE56.mjs.map → hooks-Cpu0rEMv.mjs.map} +1 -1
  19. package/dist/{index-DSdbSQ_t.d.cts → index-BXRsEYL9.d.cts} +248 -242
  20. package/dist/index-BXRsEYL9.d.cts.map +1 -0
  21. package/dist/{index-Dj9odADH.d.mts → index-Cxrz-hoe.d.mts} +248 -242
  22. package/dist/index-Cxrz-hoe.d.mts.map +1 -0
  23. package/dist/{index-B7B1tRPl.d.cts → index-DSGDEjP7.d.cts} +2 -2
  24. package/dist/{index-3GY7T8JM.d.mts.map → index-DSGDEjP7.d.cts.map} +1 -1
  25. package/dist/{index-3GY7T8JM.d.mts → index-h56yzXZy.d.mts} +2 -2
  26. package/dist/{index-B7B1tRPl.d.cts.map → index-h56yzXZy.d.mts.map} +1 -1
  27. package/dist/index.cjs +434 -173
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +2 -2
  30. package/dist/index.d.mts +2 -2
  31. package/dist/index.mjs +435 -174
  32. package/dist/index.mjs.map +1 -1
  33. package/package.json +2 -2
  34. package/dist/ecs-3kimUV5Z.mjs.map +0 -1
  35. package/dist/ecs-B4QrqfvQ.cjs.map +0 -1
  36. package/dist/index-DSdbSQ_t.d.cts.map +0 -1
  37. package/dist/index-Dj9odADH.d.mts.map +0 -1
package/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_hooks = require("./hooks-CtP02JNt.cjs");
3
- const require_ecs = require("./ecs-B4QrqfvQ.cjs");
2
+ const require_hooks = require("./hooks-B-UPFgGj.cjs");
3
+ const require_ecs = require("./ecs-BtX_rCS3.cjs");
4
4
  let _jamesyong42_reactive_ecs = require("@jamesyong42/reactive-ecs");
5
5
  let react = require("react");
6
6
  react = require_hooks.__toESM(react, 1);
@@ -281,6 +281,7 @@ function clamp(value, min, max) {
281
281
  */
282
282
  const breakpointSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
283
283
  name: "breakpoint",
284
+ phase: "derive",
284
285
  after: "cull",
285
286
  execute: (world) => {
286
287
  const camera = world.getResource(require_ecs.CameraResource);
@@ -325,6 +326,7 @@ const breakpointSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
325
326
  */
326
327
  const cardSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
327
328
  name: "card",
329
+ phase: "derive",
328
330
  execute: (world) => {
329
331
  const resource = world.getResource(require_ecs.CardPresetsResource);
330
332
  if (!resource) return;
@@ -343,6 +345,94 @@ const cardSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
343
345
  }
344
346
  });
345
347
  //#endregion
348
+ //#region src/ecs/systems/cleanup.ts
349
+ /**
350
+ * `cleanup`-phase systems that close out a tick. They run in this order
351
+ * (enforced via within-phase `after:` constraints):
352
+ *
353
+ * clearDirty → incrementTick → emitFrame → tweenKeepalive
354
+ *
355
+ * 1. `clearDirty` — clear the World's per-frame `queryChanged` /
356
+ * `queryAdded` buffers AND reset the engine-level
357
+ * `EngineDirtyResource.dirty` flag.
358
+ * 2. `incrementTick` — bump `world.currentTick`. Must come after
359
+ * `clearDirty` so subscribers triggered by
360
+ * `emitFrame` see the new tick number.
361
+ * 3. `emitFrame` — notify `onFrame` subscribers (`EngineInvalidator`,
362
+ * widget state machine, React hooks).
363
+ * 4. `tweenKeepalive` — RFC-004 § Phase 2/4. If any `TransformTween` is
364
+ * still alive, re-set `EngineDirtyResource.dirty`
365
+ * so the next rAF wakes the engine and the
366
+ * animation keeps running. Must run *after*
367
+ * `clearDirty`'s reset; otherwise its write would
368
+ * be clobbered.
369
+ *
370
+ * RFC-010 Phase 4 — replaces lines 887–903 of the inline tail of
371
+ * `engine.tick()`.
372
+ */
373
+ const clearDirtySystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
374
+ name: "clearDirty",
375
+ phase: "cleanup",
376
+ execute: (world) => {
377
+ world.clearDirty();
378
+ world.setResource(require_ecs.EngineDirtyResource, { dirty: false });
379
+ }
380
+ });
381
+ const incrementTickSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
382
+ name: "incrementTick",
383
+ phase: "cleanup",
384
+ after: "clearDirty",
385
+ execute: (world) => {
386
+ world.incrementTick();
387
+ }
388
+ });
389
+ const emitFrameSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
390
+ name: "emitFrame",
391
+ phase: "cleanup",
392
+ after: "incrementTick",
393
+ execute: (world) => {
394
+ world.emitFrame();
395
+ }
396
+ });
397
+ const tweenKeepaliveSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
398
+ name: "tweenKeepalive",
399
+ phase: "cleanup",
400
+ after: "emitFrame",
401
+ execute: (world) => {
402
+ for (const _ of world.query(require_ecs.TransformTween)) {
403
+ world.setResource(require_ecs.EngineDirtyResource, { dirty: true });
404
+ return;
405
+ }
406
+ }
407
+ });
408
+ //#endregion
409
+ //#region src/ecs/systems/container-camera.ts
410
+ /**
411
+ * Auto-attach a default `ContainerCamera` on every entity that just received
412
+ * `Container`, so each container has a usable per-frame camera from birth.
413
+ * Serialization round-trips cleanly; `enterContainer` can read without
414
+ * falling back.
415
+ *
416
+ * Diff signal: `world.queryAdded(Container)` — only entities that received
417
+ * the component *this tick*, matching the original observer's
418
+ * `prev === undefined` guard. Mutating an existing `Container.enterable`
419
+ * does not re-stamp a fresh camera.
420
+ *
421
+ * RFC-010 Phase 3 — migrates the `Container` add observer at
422
+ * `LayoutEngine.ts:147–155` into a `react`-phase system.
423
+ */
424
+ const containerCameraSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
425
+ name: "containerCamera",
426
+ phase: "react",
427
+ execute: (world) => {
428
+ for (const entity of world.queryAdded(require_ecs.Container)) if (!world.hasComponent(entity, require_ecs.ContainerCamera)) world.addComponent(entity, require_ecs.ContainerCamera, {
429
+ x: 0,
430
+ y: 0,
431
+ zoom: 1
432
+ });
433
+ }
434
+ });
435
+ //#endregion
346
436
  //#region src/ecs/systems/cull.ts
347
437
  /**
348
438
  * Viewport culling — for every `Active` entity, sets exactly one of `Visible`
@@ -355,7 +445,7 @@ const cardSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
355
445
  */
356
446
  const cullSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
357
447
  name: "cull",
358
- after: "navigationFilter",
448
+ phase: "derive",
359
449
  execute: (world) => {
360
450
  const camera = world.getResource(require_ecs.CameraResource);
361
451
  const viewport = world.getResource(require_ecs.ViewportResource);
@@ -400,14 +490,13 @@ const cullSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
400
490
  * Dragging present, no PreDragLayer → promote (stash old layer, set overlay)
401
491
  * PreDragLayer present, no Dragging → restore (write back stashed layer)
402
492
  *
403
- * RFC-010 Phase 1 migrates the two `onTagAdded(Dragging)` /
404
- * `onTagRemoved(Dragging)` observers out of `LayoutEngine.ts` into a
405
- * `SystemScheduler` system. The `before: 'cull'` constraint is conservative
406
- * and becomes implicit once RFC-010 Phase 2 stamps `phase: 'react'`.
493
+ * RFC-010 runs in the `react` phase so the promote/restore is settled
494
+ * before `derive`-phase systems (`cull`, `breakpoint`, `sort`) and the
495
+ * `present`-phase visibility / frame-changes assembly run.
407
496
  */
408
497
  const dragPromoteSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
409
498
  name: "dragPromote",
410
- before: "cull",
499
+ phase: "react",
411
500
  execute: (world) => {
412
501
  for (const entity of world.queryTagged(require_ecs.Dragging)) {
413
502
  if (world.hasComponent(entity, require_ecs.PreDragLayer)) continue;
@@ -428,6 +517,89 @@ const dragPromoteSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
428
517
  }
429
518
  });
430
519
  //#endregion
520
+ //#region src/ecs/systems/frame-changes.ts
521
+ /**
522
+ * Assemble the `FrameChanges` snapshot consumed by `<InfiniteCanvas>`'s
523
+ * rAF tail (DOM transform writes, R3F invalidate, layer re-bucket, event
524
+ * callbacks). Runs in `present` after `visibility` so the prev/current
525
+ * sets are populated.
526
+ *
527
+ * Inputs:
528
+ * - `world.queryChanged(Transform2D | WidgetBreakpoint | ZIndex | Layer)`
529
+ * — entityId arrays for each tracked component change.
530
+ * - `VisibleEntitiesResource.{current, prev}` — set diffing for entered
531
+ * and exited.
532
+ * - `TickFlagsResource` — camera / selection / navigation flags set out
533
+ * of band by engine APIs and `navStackCaptureSystem`.
534
+ *
535
+ * Side effects:
536
+ * - Writes `FrameChangesResource.changes`.
537
+ * - Resets `TickFlagsResource.cameraChanged` and `selectionChanged` to
538
+ * `false` (the flags are per-tick; consuming them clears them so the
539
+ * next tick starts fresh). `navigationChangedSnapshot` is overwritten
540
+ * by `navStackCaptureSystem` next tick — no need to reset here.
541
+ *
542
+ * RFC-010 Phase 4 — replaces lines 859–883 of the inline tail of
543
+ * `engine.tick()`.
544
+ */
545
+ const frameChangesSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
546
+ name: "frameChanges",
547
+ phase: "present",
548
+ after: "visibility",
549
+ execute: (world) => {
550
+ const visible = world.getResource(require_ecs.VisibleEntitiesResource);
551
+ const flags = world.getResource(require_ecs.TickFlagsResource);
552
+ const newSet = /* @__PURE__ */ new Set();
553
+ for (const v of visible.current) newSet.add(v.entityId);
554
+ const entered = [];
555
+ const exited = [];
556
+ for (const id of newSet) if (!visible.prev.has(id)) entered.push(id);
557
+ for (const id of visible.prev) if (!newSet.has(id)) exited.push(id);
558
+ world.setResource(require_ecs.FrameChangesResource, { changes: {
559
+ positionsChanged: world.queryChanged(require_ecs.Transform2D),
560
+ breakpointsChanged: world.queryChanged(require_ecs.WidgetBreakpoint),
561
+ zIndicesChanged: world.queryChanged(require_ecs.ZIndex),
562
+ entered,
563
+ exited,
564
+ cameraChanged: flags.cameraChanged,
565
+ navigationChanged: flags.navigationChangedSnapshot,
566
+ selectionChanged: flags.selectionChanged,
567
+ layersChanged: world.queryChanged(require_ecs.Layer).length > 0
568
+ } });
569
+ world.setResource(require_ecs.TickFlagsResource, {
570
+ cameraChanged: false,
571
+ selectionChanged: false
572
+ });
573
+ }
574
+ });
575
+ //#endregion
576
+ //#region src/ecs/systems/nav-stack-capture.ts
577
+ /**
578
+ * Snapshot `NavigationStackResource.changed` into
579
+ * `TickFlagsResource.navigationChangedSnapshot` BEFORE
580
+ * `navigationFilterSystem` (`control` phase) resets the flag mid-tick.
581
+ *
582
+ * RFC-004 § Phase 0c invariant — `navigationFilter` mutates
583
+ * `navStack.changed = false` as its reset signal; reading the flag after
584
+ * `control` would always see false and this-tick navigation pushes/pops
585
+ * would silently miss their `FrameChanges.navigationChanged` notification.
586
+ *
587
+ * Lives in `input` (the earliest phase) so the snapshot is taken before
588
+ * any other engine system runs. `frameChangesSystem` (`present` phase)
589
+ * later reads the snapshot when assembling `FrameChanges`.
590
+ *
591
+ * RFC-010 Phase 4 — replaces the inline pre-`scheduler.execute` capture
592
+ * at lines 815–816 of the previous `engine.tick()` body.
593
+ */
594
+ const navStackCaptureSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
595
+ name: "navStackCapture",
596
+ phase: "input",
597
+ execute: (world) => {
598
+ const navStack = world.getResource(require_ecs.NavigationStackResource);
599
+ world.setResource(require_ecs.TickFlagsResource, { navigationChangedSnapshot: navStack?.changed ?? false });
600
+ }
601
+ });
602
+ //#endregion
431
603
  //#region src/ecs/systems/navigation-filter.ts
432
604
  /**
433
605
  * Reconcile `Active` for a single entity against the given nav frame.
@@ -455,6 +627,7 @@ function reconcileEntityActive(world, entity) {
455
627
  */
456
628
  const navigationFilterSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
457
629
  name: "navigationFilter",
630
+ phase: "control",
458
631
  execute: (world) => {
459
632
  const navStack = world.getResource(require_ecs.NavigationStackResource);
460
633
  const stackChanged = navStack.changed;
@@ -473,12 +646,101 @@ const navigationFilterSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
473
646
  }
474
647
  });
475
648
  //#endregion
649
+ //#region src/ecs/systems/parent-frame-active.ts
650
+ /**
651
+ * Keep `Active` in sync with mid-session `ParentFrame` mutations (RFC-004
652
+ * § Phase 5). Covers the consume path (a child gets `ParentFrame` and
653
+ * should leave the current frame) and re-parenting (the id changes).
654
+ * Without this, a consumed card would retain `Active` at root until the
655
+ * next nav-stack change and would render on top of its own container.
656
+ *
657
+ * Diff signal: `world.queryChanged(ParentFrame)` — entities whose
658
+ * `ParentFrame` was added or set this tick (same set the previous
659
+ * `onComponentChanged` observer fired for).
660
+ *
661
+ * **Limitation: `ParentFrame` removal is not handled here.** Neither
662
+ * `reactive-ecs`'s `onComponentChanged` observer (the previous
663
+ * implementation) nor `queryChanged` emits on `removeComponent`, so the
664
+ * "undo (ParentFrame removed, child returns to root)" case described in
665
+ * the original RFC-004 § Phase 5 comment was never actually covered by
666
+ * this code path. It is recovered by `navigationFilterSystem`'s full
667
+ * refilter on the next `navStack.changed` event. If an `Active` invariant
668
+ * needs to be live across removes, either add `world.queryRemoved` to
669
+ * reactive-ecs or push `navStack.changed = true` from the remover.
670
+ *
671
+ * RFC-010 Phase 3 — migrates the `ParentFrame` observer at
672
+ * `LayoutEngine.ts:165–170` into a `react`-phase system.
673
+ */
674
+ const parentFrameActiveSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
675
+ name: "parentFrameActive",
676
+ phase: "react",
677
+ execute: (world) => {
678
+ for (const entity of world.queryChanged(require_ecs.ParentFrame)) reconcileEntityActive(world, entity);
679
+ }
680
+ });
681
+ //#endregion
682
+ //#region src/ecs/systems/role-refresh.ts
683
+ /**
684
+ * Reconcile `InteractionRole` and `CursorHint` for a single entity against
685
+ * its current `Draggable` / `Selectable` tag state. Bails if the entity has
686
+ * a custom role (rotate, connect, etc.) — only the default drag/select/
687
+ * canvas roles are auto-managed.
688
+ */
689
+ function refreshInteractionRole(world, entity) {
690
+ const current = world.getComponent(entity, require_ecs.InteractionRole);
691
+ if (current && current.role.type !== "drag" && current.role.type !== "select" && current.role.type !== "canvas") return;
692
+ const hasDraggable = world.hasTag(entity, require_ecs.Draggable);
693
+ const hasSelectable = world.hasTag(entity, require_ecs.Selectable);
694
+ const desiredRole = hasDraggable ? { type: "drag" } : hasSelectable ? { type: "select" } : null;
695
+ if (desiredRole === null) {
696
+ if (current) world.removeComponent(entity, require_ecs.InteractionRole);
697
+ if (world.hasComponent(entity, require_ecs.CursorHint)) world.removeComponent(entity, require_ecs.CursorHint);
698
+ return;
699
+ }
700
+ if (!current) world.addComponent(entity, require_ecs.InteractionRole, {
701
+ layer: 5,
702
+ role: desiredRole
703
+ });
704
+ else if (current.role.type !== desiredRole.type) world.setComponent(entity, require_ecs.InteractionRole, { role: desiredRole });
705
+ if (desiredRole.type === "drag" && !world.hasComponent(entity, require_ecs.CursorHint)) world.addComponent(entity, require_ecs.CursorHint, {
706
+ hover: "grab",
707
+ active: "grabbing"
708
+ });
709
+ }
710
+ /**
711
+ * Auto-attach `InteractionRole` and `CursorHint` based on `Draggable` /
712
+ * `Selectable` tag presence. Entities with an explicit non-drag/non-select
713
+ * role (rotate/connect/etc.) are left alone.
714
+ *
715
+ * Diff signal: `reactive-ecs` has no `queryAddedTag` / `queryRemovedTag`
716
+ * primitive, so this system recomputes idempotently each tick over the
717
+ * union {Draggable ∪ Selectable ∪ InteractionRole}. The refresh helper
718
+ * skips entities whose role is already correct, so steady-state writes are
719
+ * zero.
720
+ *
721
+ * RFC-010 Phase 3 — migrates the four tag observers at
722
+ * `LayoutEngine.ts:175–213` (Draggable/Selectable add/remove) into a
723
+ * `react`-phase system.
724
+ */
725
+ const roleRefreshSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
726
+ name: "roleRefresh",
727
+ phase: "react",
728
+ execute: (world) => {
729
+ const candidates = /* @__PURE__ */ new Set();
730
+ for (const e of world.queryTagged(require_ecs.Draggable)) candidates.add(e);
731
+ for (const e of world.queryTagged(require_ecs.Selectable)) candidates.add(e);
732
+ for (const e of world.query(require_ecs.InteractionRole)) candidates.add(e);
733
+ for (const entity of candidates) refreshInteractionRole(world, entity);
734
+ }
735
+ });
736
+ //#endregion
476
737
  //#region src/ecs/systems/sort.ts
477
738
  /**
478
739
  * Sort visible entities by z-index (handled in engine.tick()).
479
740
  */
480
741
  const sortSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
481
742
  name: "sort",
743
+ phase: "derive",
482
744
  after: "breakpoint",
483
745
  execute: (_world) => {}
484
746
  });
@@ -516,6 +778,7 @@ function applyEasing(p, easing) {
516
778
  */
517
779
  const transformTweenSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
518
780
  name: "transformTween",
781
+ phase: "simulate",
519
782
  execute: (world) => {
520
783
  const nowMs = typeof performance !== "undefined" ? performance.now() : Date.now();
521
784
  for (const entity of world.query(require_ecs.TransformTween)) {
@@ -544,6 +807,60 @@ const transformTweenSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
544
807
  }
545
808
  });
546
809
  //#endregion
810
+ //#region src/ecs/systems/visibility.ts
811
+ /**
812
+ * Build the per-frame visible-entity list consumed by `InfiniteCanvas`.
813
+ *
814
+ * Iterates `query(Widget, Visible)` (the latter tag is set by `cullSystem`
815
+ * in the same `derive` phase that ran earlier this tick), pulls render-
816
+ * relevant fields off `Transform2D` / `WidgetBreakpoint` / `ZIndex`, sorts
817
+ * by `zIndex`, and writes the resulting array into `VisibleEntitiesResource.
818
+ * current`. The previous tick's entityId set is also preserved on
819
+ * `.prev` so `frameChangesSystem` can compute entered/exited.
820
+ *
821
+ * RFC-010 Phase 4 — replaces lines 830–857 of the inline tail of
822
+ * `engine.tick()`. Profiler instrumentation flows automatically through
823
+ * `SystemProfiler.beginSystem('visibility')`; the previously-dedicated
824
+ * `Profiler.beginVisibility / endVisibility` calls are dropped (the
825
+ * exported `visibilityMs` stat field becomes vestigial — `systemAvg.
826
+ * visibility` carries the same signal).
827
+ */
828
+ const visibilitySystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
829
+ name: "visibility",
830
+ phase: "present",
831
+ execute: (world) => {
832
+ const newVisible = [];
833
+ const newVisibleSet = /* @__PURE__ */ new Set();
834
+ for (const entity of world.query(require_ecs.Widget, require_ecs.Visible)) {
835
+ const t = world.getComponent(entity, require_ecs.Transform2D);
836
+ const widget = world.getComponent(entity, require_ecs.Widget);
837
+ const bp = world.getComponent(entity, require_ecs.WidgetBreakpoint);
838
+ const zIdx = world.getComponent(entity, require_ecs.ZIndex);
839
+ if (!t || !widget) continue;
840
+ newVisibleSet.add(entity);
841
+ newVisible.push({
842
+ entityId: entity,
843
+ x: t.x,
844
+ y: t.y,
845
+ width: t.width,
846
+ height: t.height,
847
+ breakpoint: bp?.current ?? "normal",
848
+ zIndex: zIdx?.value ?? 0,
849
+ surface: widget.surface,
850
+ widgetType: widget.type
851
+ });
852
+ }
853
+ newVisible.sort((a, b) => a.zIndex - b.zIndex);
854
+ const previous = world.getResource(require_ecs.VisibleEntitiesResource);
855
+ const prev = /* @__PURE__ */ new Set();
856
+ for (const v of previous.current) prev.add(v.entityId);
857
+ world.setResource(require_ecs.VisibleEntitiesResource, {
858
+ current: newVisible,
859
+ prev
860
+ });
861
+ }
862
+ });
863
+ //#endregion
547
864
  //#region src/ecs/engine/interaction.ts
548
865
  /**
549
866
  * Hit-zone size for resize hotspots (full width, screen px). Deliberately
@@ -913,6 +1230,17 @@ function createInteractionRuntime(ctx) {
913
1230
  }
914
1231
  world.setResource(require_ecs.CursorResource, { cursor });
915
1232
  }
1233
+ const flyBackSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
1234
+ name: "flyBack",
1235
+ phase: "simulate",
1236
+ after: "transformTween",
1237
+ execute: () => runFlyBackSystem()
1238
+ });
1239
+ const cursorSystem = (0, _jamesyong42_reactive_ecs.defineSystem)({
1240
+ name: "cursor",
1241
+ phase: "present",
1242
+ execute: () => runCursorSystem()
1243
+ });
916
1244
  /**
917
1245
  * Transition to `dragging` mode. Snapshots ZIndex + Transform2D for every
918
1246
  * `Selected` entity, elevates them to `maxZ + 1`, tags `Dragging`, opens a
@@ -1440,8 +1768,8 @@ function createInteractionRuntime(ctx) {
1440
1768
  handlePointerMove,
1441
1769
  handlePointerUp,
1442
1770
  handlePointerCancel,
1443
- runCursorSystem,
1444
- runFlyBackSystem,
1771
+ flyBackSystem,
1772
+ cursorSystem,
1445
1773
  selectEntity,
1446
1774
  clearSelection,
1447
1775
  getHoveredEntity: () => hoveredEntity,
@@ -1483,6 +1811,35 @@ function createInteractionRuntime(ctx) {
1483
1811
  };
1484
1812
  }
1485
1813
  //#endregion
1814
+ //#region src/ecs/engine/phases.ts
1815
+ /**
1816
+ * Pipeline phases for the infinite-canvas `LayoutEngine`.
1817
+ *
1818
+ * `reactive-ecs` ships zero phase vocabulary — phase names and order are the
1819
+ * consumer's responsibility. These names are infinite-canvas's choice; a UI
1820
+ * tool, a game engine, and an agent simulator would each pick differently.
1821
+ *
1822
+ * Phase intent:
1823
+ * - `input` — drain external intent (gestures, raw flag captures) into the world
1824
+ * - `react` — maintain invariants in response to mutations from prior writes
1825
+ * - `control` — state machines, intent resolution, navigation
1826
+ * - `simulate` — time-driven mutations (tweens, animation)
1827
+ * - `derive` — compute frame-local derived state (visibility, sort, layout)
1828
+ * - `present` — build outputs for renderers (frame-changes, visible lists)
1829
+ * - `cleanup` — end-of-frame bookkeeping (clearDirty, incrementTick, emitFrame)
1830
+ *
1831
+ * See RFC-010 for the full architectural rationale.
1832
+ */
1833
+ const ENGINE_PHASES = [
1834
+ "input",
1835
+ "react",
1836
+ "control",
1837
+ "simulate",
1838
+ "derive",
1839
+ "present",
1840
+ "cleanup"
1841
+ ];
1842
+ //#endregion
1486
1843
  //#region src/ecs/engine/widget-binding.ts
1487
1844
  function createWidgetRegistry(defs = []) {
1488
1845
  const map = /* @__PURE__ */ new Map();
@@ -1506,8 +1863,31 @@ function createWidgetRegistry(defs = []) {
1506
1863
  * This is the main entry point for the infinite canvas library.
1507
1864
  */
1508
1865
  function createLayoutEngine(config) {
1509
- const world = (0, _jamesyong42_reactive_ecs.createWorld)();
1510
- const scheduler = new _jamesyong42_reactive_ecs.SystemScheduler();
1866
+ const rawWorld = (0, _jamesyong42_reactive_ecs.createWorld)();
1867
+ const DIRTYING_METHODS = new Set([
1868
+ "createEntity",
1869
+ "destroyEntity",
1870
+ "addComponent",
1871
+ "removeComponent",
1872
+ "setComponent",
1873
+ "addTag",
1874
+ "removeTag"
1875
+ ]);
1876
+ const world = new Proxy(rawWorld, { get(target, prop, receiver) {
1877
+ if (typeof prop === "string" && DIRTYING_METHODS.has(prop)) {
1878
+ const fn = Reflect.get(target, prop, target);
1879
+ return (...args) => {
1880
+ const result = fn.apply(target, args);
1881
+ rawWorld.setResource(require_ecs.EngineDirtyResource, { dirty: true });
1882
+ return result;
1883
+ };
1884
+ }
1885
+ return Reflect.get(target, prop, receiver);
1886
+ } });
1887
+ const scheduler = new _jamesyong42_reactive_ecs.PhasedScheduler({
1888
+ phases: ENGINE_PHASES,
1889
+ defaultPhase: "derive"
1890
+ });
1511
1891
  const spatialIndex = new require_hooks.SpatialIndex();
1512
1892
  const profiler = new require_hooks.Profiler();
1513
1893
  scheduler.profiler = profiler;
@@ -1530,13 +1910,23 @@ function createLayoutEngine(config) {
1530
1910
  let snapEnabled = config?.snap?.enabled ?? true;
1531
1911
  let snapThreshold = config?.snap?.threshold ?? 5;
1532
1912
  let snapGuidesVisible = config?.snap?.guidesVisible ?? true;
1533
- scheduler.register(cardSystem);
1534
- scheduler.register(transformTweenSystem);
1913
+ scheduler.register(navStackCaptureSystem);
1914
+ scheduler.register(dragPromoteSystem);
1915
+ scheduler.register(containerCameraSystem);
1916
+ scheduler.register(parentFrameActiveSystem);
1917
+ scheduler.register(roleRefreshSystem);
1535
1918
  scheduler.register(navigationFilterSystem);
1919
+ scheduler.register(transformTweenSystem);
1920
+ scheduler.register(cardSystem);
1536
1921
  scheduler.register(cullSystem);
1537
1922
  scheduler.register(breakpointSystem);
1538
1923
  scheduler.register(sortSystem);
1539
- scheduler.register(dragPromoteSystem);
1924
+ scheduler.register(visibilitySystem);
1925
+ scheduler.register(frameChangesSystem);
1926
+ scheduler.register(clearDirtySystem);
1927
+ scheduler.register(incrementTickSystem);
1928
+ scheduler.register(emitFrameSystem);
1929
+ scheduler.register(tweenKeepaliveSystem);
1540
1930
  const unsubscribers = [];
1541
1931
  unsubscribers.push(world.onComponentChanged(require_ecs.Transform2D, (entityId, _prev, t) => {
1542
1932
  if (t) spatialIndex.upsert(entityId, rectToAABB(t));
@@ -1544,65 +1934,14 @@ function createLayoutEngine(config) {
1544
1934
  unsubscribers.push(world.onEntityDestroyed((entity) => {
1545
1935
  spatialIndex.remove(entity);
1546
1936
  }));
1547
- unsubscribers.push(world.onComponentChanged(require_ecs.Container, (entityId, prev, next) => {
1548
- if (prev === void 0 && next !== void 0) {
1549
- if (!world.hasComponent(entityId, require_ecs.ContainerCamera)) world.addComponent(entityId, require_ecs.ContainerCamera, {
1550
- x: 0,
1551
- y: 0,
1552
- zoom: 1
1553
- });
1554
- }
1555
- }));
1556
- unsubscribers.push(world.onComponentChanged(require_ecs.ParentFrame, (entityId) => {
1557
- reconcileEntityActive(world, entityId);
1558
- markDirtyInternal();
1559
- }));
1560
- function refreshInteractionRole(entity) {
1561
- const current = world.getComponent(entity, require_ecs.InteractionRole);
1562
- if (current && current.role.type !== "drag" && current.role.type !== "select" && current.role.type !== "canvas") return;
1563
- const hasDraggable = world.hasTag(entity, require_ecs.Draggable);
1564
- const hasSelectable = world.hasTag(entity, require_ecs.Selectable);
1565
- const desiredRole = hasDraggable ? { type: "drag" } : hasSelectable ? { type: "select" } : null;
1566
- if (desiredRole === null) {
1567
- if (current) world.removeComponent(entity, require_ecs.InteractionRole);
1568
- if (world.hasComponent(entity, require_ecs.CursorHint)) world.removeComponent(entity, require_ecs.CursorHint);
1569
- return;
1570
- }
1571
- if (!current) world.addComponent(entity, require_ecs.InteractionRole, {
1572
- layer: 5,
1573
- role: desiredRole
1574
- });
1575
- else if (current.role.type !== desiredRole.type) world.setComponent(entity, require_ecs.InteractionRole, { role: desiredRole });
1576
- if (desiredRole.type === "drag" && !world.hasComponent(entity, require_ecs.CursorHint)) world.addComponent(entity, require_ecs.CursorHint, {
1577
- hover: "grab",
1578
- active: "grabbing"
1579
- });
1580
- }
1581
- unsubscribers.push(world.onTagAdded(require_ecs.Draggable, refreshInteractionRole));
1582
- unsubscribers.push(world.onTagRemoved(require_ecs.Draggable, refreshInteractionRole));
1583
- unsubscribers.push(world.onTagAdded(require_ecs.Selectable, refreshInteractionRole));
1584
- unsubscribers.push(world.onTagRemoved(require_ecs.Selectable, refreshInteractionRole));
1585
1937
  if (config?.widgets) for (const w of config.widgets) widgetRegistry.register(w);
1586
1938
  if (config?.archetypes) for (const a of config.archetypes) archetypeRegistry.register(a);
1587
1939
  world.setResource(require_ecs.NavigationStackResource, { changed: true });
1588
- let dirty = false;
1589
- let cameraChangedThisTick = false;
1590
- let selectionChangedThisTick = false;
1591
- let prevVisible = /* @__PURE__ */ new Set();
1592
- let currentVisible = [];
1593
- let frameChanges = {
1594
- positionsChanged: [],
1595
- breakpointsChanged: [],
1596
- zIndicesChanged: [],
1597
- entered: [],
1598
- exited: [],
1599
- cameraChanged: false,
1600
- navigationChanged: false,
1601
- selectionChanged: false,
1602
- layersChanged: false
1603
- };
1604
1940
  function markDirtyInternal() {
1605
- dirty = true;
1941
+ world.setResource(require_ecs.EngineDirtyResource, { dirty: true });
1942
+ }
1943
+ function markCameraChanged() {
1944
+ world.setResource(require_ecs.TickFlagsResource, { cameraChanged: true });
1606
1945
  }
1607
1946
  const interaction = createInteractionRuntime({
1608
1947
  world,
@@ -1610,12 +1949,14 @@ function createLayoutEngine(config) {
1610
1949
  commandBuffer,
1611
1950
  markDirty: markDirtyInternal,
1612
1951
  notifySelectionChanged: () => {
1613
- selectionChangedThisTick = true;
1952
+ world.setResource(require_ecs.TickFlagsResource, { selectionChanged: true });
1614
1953
  },
1615
1954
  getSnapEnabled: () => snapEnabled,
1616
1955
  getSnapThreshold: () => snapThreshold,
1617
1956
  getWidgetInteraction: (type) => widgetRegistry.get(type)?.interaction
1618
1957
  });
1958
+ scheduler.register(interaction.flyBackSystem);
1959
+ scheduler.register(interaction.cursorSystem);
1619
1960
  const engine = {
1620
1961
  world,
1621
1962
  createEntity(inits) {
@@ -1625,7 +1966,6 @@ function createLayoutEngine(config) {
1625
1966
  if (type.__kind === "tag") world.addTag(entity, type);
1626
1967
  else world.addComponent(entity, type, init[1] ?? {});
1627
1968
  }
1628
- markDirtyInternal();
1629
1969
  return entity;
1630
1970
  },
1631
1971
  spawn(id, opts = {}) {
@@ -1741,14 +2081,12 @@ function createLayoutEngine(config) {
1741
2081
  destroyEntity(id) {
1742
2082
  spatialIndex.remove(id);
1743
2083
  world.destroyEntity(id);
1744
- markDirtyInternal();
1745
2084
  },
1746
2085
  get(entity, type) {
1747
2086
  return world.getComponent(entity, type);
1748
2087
  },
1749
2088
  set(entity, type, data) {
1750
2089
  world.setComponent(entity, type, data);
1751
- markDirtyInternal();
1752
2090
  },
1753
2091
  has(entity, type) {
1754
2092
  if (type.__kind === "tag") return world.hasTag(entity, type);
@@ -1756,19 +2094,15 @@ function createLayoutEngine(config) {
1756
2094
  },
1757
2095
  addComponent(entity, type, data) {
1758
2096
  world.addComponent(entity, type, data ?? type.defaults);
1759
- markDirtyInternal();
1760
2097
  },
1761
2098
  removeComponent(entity, type) {
1762
2099
  world.removeComponent(entity, type);
1763
- markDirtyInternal();
1764
2100
  },
1765
2101
  addTag(entity, type) {
1766
2102
  world.addTag(entity, type);
1767
- markDirtyInternal();
1768
2103
  },
1769
2104
  removeTag(entity, type) {
1770
2105
  world.removeTag(entity, type);
1771
- markDirtyInternal();
1772
2106
  },
1773
2107
  getSchemaFor(entity) {
1774
2108
  const w = world.getComponent(entity, require_ecs.Widget);
@@ -1788,7 +2122,7 @@ function createLayoutEngine(config) {
1788
2122
  const camera = world.getResource(require_ecs.CameraResource);
1789
2123
  camera.x -= dx / camera.zoom;
1790
2124
  camera.y -= dy / camera.zoom;
1791
- cameraChangedThisTick = true;
2125
+ markCameraChanged();
1792
2126
  markDirtyInternal();
1793
2127
  },
1794
2128
  panTo(worldX, worldY) {
@@ -1796,7 +2130,7 @@ function createLayoutEngine(config) {
1796
2130
  const viewport = world.getResource(require_ecs.ViewportResource);
1797
2131
  camera.x = worldX - viewport.width / (2 * camera.zoom);
1798
2132
  camera.y = worldY - viewport.height / (2 * camera.zoom);
1799
- cameraChangedThisTick = true;
2133
+ markCameraChanged();
1800
2134
  markDirtyInternal();
1801
2135
  },
1802
2136
  zoomAtPoint(screenX, screenY, delta) {
@@ -1807,7 +2141,7 @@ function createLayoutEngine(config) {
1807
2141
  camera.zoom = newZoom;
1808
2142
  camera.x = worldBefore.x - screenX / newZoom;
1809
2143
  camera.y = worldBefore.y - screenY / newZoom;
1810
- cameraChangedThisTick = true;
2144
+ markCameraChanged();
1811
2145
  markDirtyInternal();
1812
2146
  },
1813
2147
  zoomTo(zoom) {
@@ -1819,7 +2153,7 @@ function createLayoutEngine(config) {
1819
2153
  camera.zoom = clamp(zoom, zoomConfig.min, zoomConfig.max);
1820
2154
  camera.x = centerWorldX - viewport.width / (2 * camera.zoom);
1821
2155
  camera.y = centerWorldY - viewport.height / (2 * camera.zoom);
1822
- cameraChangedThisTick = true;
2156
+ markCameraChanged();
1823
2157
  markDirtyInternal();
1824
2158
  },
1825
2159
  /**
@@ -1833,7 +2167,7 @@ function createLayoutEngine(config) {
1833
2167
  const camera = world.getResource(require_ecs.CameraResource);
1834
2168
  if (camera.gesturing === active) return;
1835
2169
  camera.gesturing = active;
1836
- cameraChangedThisTick = true;
2170
+ markCameraChanged();
1837
2171
  markDirtyInternal();
1838
2172
  },
1839
2173
  zoomToFit(entityIds, padding = 50) {
@@ -1862,7 +2196,7 @@ function createLayoutEngine(config) {
1862
2196
  camera.zoom = zoom;
1863
2197
  camera.x = minX - padding - (viewport.width / zoom - contentWidth) / 2;
1864
2198
  camera.y = minY - padding - (viewport.height / zoom - contentHeight) / 2;
1865
- cameraChangedThisTick = true;
2199
+ markCameraChanged();
1866
2200
  markDirtyInternal();
1867
2201
  },
1868
2202
  setViewport(width, height, dpr) {
@@ -1884,14 +2218,10 @@ function createLayoutEngine(config) {
1884
2218
  commandBuffer.endGroup();
1885
2219
  },
1886
2220
  undo() {
1887
- const did = commandBuffer.undo(world);
1888
- if (did) markDirtyInternal();
1889
- return did;
2221
+ return commandBuffer.undo(world);
1890
2222
  },
1891
2223
  redo() {
1892
- const did = commandBuffer.redo(world);
1893
- if (did) markDirtyInternal();
1894
- return did;
2224
+ return commandBuffer.redo(world);
1895
2225
  },
1896
2226
  canUndo() {
1897
2227
  return commandBuffer.canUndo();
@@ -2024,7 +2354,7 @@ function createLayoutEngine(config) {
2024
2354
  camera.y = incoming.y;
2025
2355
  camera.zoom = incoming.zoom;
2026
2356
  interaction.clearSelection();
2027
- cameraChangedThisTick = true;
2357
+ markCameraChanged();
2028
2358
  markDirtyInternal();
2029
2359
  },
2030
2360
  exitContainer() {
@@ -2049,7 +2379,7 @@ function createLayoutEngine(config) {
2049
2379
  camera.y = incoming.y;
2050
2380
  camera.zoom = incoming.zoom;
2051
2381
  interaction.clearSelection();
2052
- cameraChangedThisTick = true;
2382
+ markCameraChanged();
2053
2383
  markDirtyInternal();
2054
2384
  },
2055
2385
  getActiveContainer() {
@@ -2059,79 +2389,25 @@ function createLayoutEngine(config) {
2059
2389
  getNavigationDepth() {
2060
2390
  return world.getResource(require_ecs.NavigationStackResource).frames.length - 1;
2061
2391
  },
2062
- markDirty() {
2392
+ invalidatePresent() {
2063
2393
  markDirtyInternal();
2064
2394
  },
2065
2395
  profiler,
2066
2396
  tick() {
2067
2397
  profiler.beginFrame(world.currentTick);
2068
- const navigationChangedThisTick = world.getResource(require_ecs.NavigationStackResource)?.changed ?? false;
2069
2398
  scheduler.execute(world);
2070
- interaction.runFlyBackSystem();
2071
- interaction.runCursorSystem();
2072
- profiler.beginVisibility();
2073
- const newVisible = [];
2074
- const newVisibleSet = /* @__PURE__ */ new Set();
2075
- for (const entity of world.query(require_ecs.Widget, require_ecs.Visible)) {
2076
- const t = world.getComponent(entity, require_ecs.Transform2D);
2077
- const widget = world.getComponent(entity, require_ecs.Widget);
2078
- const bp = world.getComponent(entity, require_ecs.WidgetBreakpoint);
2079
- const zIdx = world.getComponent(entity, require_ecs.ZIndex);
2080
- if (!t || !widget) continue;
2081
- newVisibleSet.add(entity);
2082
- newVisible.push({
2083
- entityId: entity,
2084
- x: t.x,
2085
- y: t.y,
2086
- width: t.width,
2087
- height: t.height,
2088
- breakpoint: bp?.current ?? "normal",
2089
- zIndex: zIdx?.value ?? 0,
2090
- surface: widget.surface,
2091
- widgetType: widget.type
2092
- });
2093
- }
2094
- newVisible.sort((a, b) => a.zIndex - b.zIndex);
2095
- profiler.endVisibility();
2096
- const entered = [];
2097
- const exited = [];
2098
- for (const entity of newVisibleSet) if (!prevVisible.has(entity)) entered.push(entity);
2099
- for (const entity of prevVisible) if (!newVisibleSet.has(entity)) exited.push(entity);
2100
- frameChanges = {
2101
- positionsChanged: world.queryChanged(require_ecs.Transform2D),
2102
- breakpointsChanged: world.queryChanged(require_ecs.WidgetBreakpoint),
2103
- zIndicesChanged: world.queryChanged(require_ecs.ZIndex),
2104
- entered,
2105
- exited,
2106
- cameraChanged: cameraChangedThisTick,
2107
- navigationChanged: navigationChangedThisTick,
2108
- selectionChanged: selectionChangedThisTick,
2109
- layersChanged: world.queryChanged(require_ecs.Layer).length > 0
2110
- };
2111
- currentVisible = newVisible;
2112
- prevVisible = newVisibleSet;
2113
- cameraChangedThisTick = false;
2114
- selectionChangedThisTick = false;
2115
- profiler.endFrame(world.entityCount, newVisible.length);
2116
- world.clearDirty();
2117
- world.incrementTick();
2118
- world.emitFrame();
2119
- dirty = false;
2120
- for (const _ of world.query(require_ecs.TransformTween)) {
2121
- dirty = true;
2122
- break;
2123
- }
2399
+ profiler.endFrame(world.entityCount, world.getResource(require_ecs.VisibleEntitiesResource).current.length);
2124
2400
  },
2125
2401
  flushIfDirty() {
2126
- if (!dirty) return false;
2402
+ if (!world.getResource(require_ecs.EngineDirtyResource).dirty) return false;
2127
2403
  engine.tick();
2128
2404
  return true;
2129
2405
  },
2130
2406
  getVisibleEntities() {
2131
- return currentVisible;
2407
+ return world.getResource(require_ecs.VisibleEntitiesResource).current;
2132
2408
  },
2133
2409
  getFrameChanges() {
2134
- return frameChanges;
2410
+ return world.getResource(require_ecs.FrameChangesResource).changes;
2135
2411
  },
2136
2412
  getSpatialIndex() {
2137
2413
  return spatialIndex;
@@ -3529,26 +3805,11 @@ const InfiniteCanvas = react.default.forwardRef(function InfiniteCanvas({ engine
3529
3805
  snapGuidesRef.current = snapGuides;
3530
3806
  }, [snapGuides]);
3531
3807
  (0, react.useImperativeHandle)(ref, () => ({
3532
- panTo: (x, y) => {
3533
- engine.panTo(x, y);
3534
- engine.markDirty();
3535
- },
3536
- zoomTo: (zoom) => {
3537
- engine.zoomTo(zoom);
3538
- engine.markDirty();
3539
- },
3540
- zoomToFit: (padding) => {
3541
- engine.zoomToFit(void 0, padding);
3542
- engine.markDirty();
3543
- },
3544
- undo: () => {
3545
- engine.undo();
3546
- engine.markDirty();
3547
- },
3548
- redo: () => {
3549
- engine.redo();
3550
- engine.markDirty();
3551
- },
3808
+ panTo: (x, y) => engine.panTo(x, y),
3809
+ zoomTo: (zoom) => engine.zoomTo(zoom),
3810
+ zoomToFit: (padding) => engine.zoomToFit(void 0, padding),
3811
+ undo: () => engine.undo(),
3812
+ redo: () => engine.redo(),
3552
3813
  getEngine: () => engine
3553
3814
  }), [engine]);
3554
3815
  const webglCanvasRef = (0, react.useRef)(null);
@@ -3610,7 +3871,7 @@ const InfiniteCanvas = react.default.forwardRef(function InfiniteCanvas({ engine
3610
3871
  }
3611
3872
  if (selection) manager.setSelectionConfig(selection);
3612
3873
  if (snapGuides) manager.setSnapGuideConfig(snapGuides);
3613
- engine.markDirty();
3874
+ engine.invalidatePresent();
3614
3875
  }, [
3615
3876
  engine,
3616
3877
  grid,
@@ -3622,7 +3883,7 @@ const InfiniteCanvas = react.default.forwardRef(function InfiniteCanvas({ engine
3622
3883
  const container = containerRef.current;
3623
3884
  if (container) applyOverlapGlowVars(container, merged);
3624
3885
  applyOverlapGlowShaderUniforms(merged);
3625
- engine.markDirty();
3886
+ engine.invalidatePresent();
3626
3887
  }, [engine, overlapGlow]);
3627
3888
  const r3fEventManagerRef = (0, react.useRef)(null);
3628
3889
  (0, react.useEffect)(() => {