@prnt/dagr-explorer 0.1.3

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 (135) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +441 -0
  4. package/dist/base.d.ts +63 -0
  5. package/dist/base.d.ts.map +1 -0
  6. package/dist/base.js +20 -0
  7. package/dist/base.js.map +1 -0
  8. package/dist/camera.d.ts +61 -0
  9. package/dist/camera.d.ts.map +1 -0
  10. package/dist/camera.js +142 -0
  11. package/dist/camera.js.map +1 -0
  12. package/dist/context.d.ts +110 -0
  13. package/dist/context.d.ts.map +1 -0
  14. package/dist/context.js +71 -0
  15. package/dist/context.js.map +1 -0
  16. package/dist/dagr-explorer.d.ts +41 -0
  17. package/dist/dagr-explorer.d.ts.map +1 -0
  18. package/dist/dagr-explorer.js +13 -0
  19. package/dist/dagr-explorer.js.map +1 -0
  20. package/dist/errors.d.ts +55 -0
  21. package/dist/errors.d.ts.map +1 -0
  22. package/dist/errors.js +57 -0
  23. package/dist/errors.js.map +1 -0
  24. package/dist/explorer-details.d.ts +44 -0
  25. package/dist/explorer-details.d.ts.map +1 -0
  26. package/dist/explorer-details.js +76 -0
  27. package/dist/explorer-details.js.map +1 -0
  28. package/dist/explorer-search.d.ts +31 -0
  29. package/dist/explorer-search.d.ts.map +1 -0
  30. package/dist/explorer-search.js +86 -0
  31. package/dist/explorer-search.js.map +1 -0
  32. package/dist/explorer-toolbar.d.ts +17 -0
  33. package/dist/explorer-toolbar.d.ts.map +1 -0
  34. package/dist/explorer-toolbar.js +44 -0
  35. package/dist/explorer-toolbar.js.map +1 -0
  36. package/dist/explorer-trace-toggle.d.ts +16 -0
  37. package/dist/explorer-trace-toggle.d.ts.map +1 -0
  38. package/dist/explorer-trace-toggle.js +8 -0
  39. package/dist/explorer-trace-toggle.js.map +1 -0
  40. package/dist/explorer-viewport.d.ts +69 -0
  41. package/dist/explorer-viewport.d.ts.map +1 -0
  42. package/dist/explorer-viewport.js +69 -0
  43. package/dist/explorer-viewport.js.map +1 -0
  44. package/dist/explorer-views.d.ts +24 -0
  45. package/dist/explorer-views.d.ts.map +1 -0
  46. package/dist/explorer-views.js +16 -0
  47. package/dist/explorer-views.js.map +1 -0
  48. package/dist/index.d.ts +48 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +33 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/isomorphic-layout-effect.d.ts +7 -0
  53. package/dist/isomorphic-layout-effect.d.ts.map +1 -0
  54. package/dist/isomorphic-layout-effect.js +7 -0
  55. package/dist/isomorphic-layout-effect.js.map +1 -0
  56. package/dist/labels.d.ts +67 -0
  57. package/dist/labels.d.ts.map +1 -0
  58. package/dist/labels.js +50 -0
  59. package/dist/labels.js.map +1 -0
  60. package/dist/layout.d.ts +82 -0
  61. package/dist/layout.d.ts.map +1 -0
  62. package/dist/layout.js +244 -0
  63. package/dist/layout.js.map +1 -0
  64. package/dist/navigation.d.ts +20 -0
  65. package/dist/navigation.d.ts.map +1 -0
  66. package/dist/navigation.js +44 -0
  67. package/dist/navigation.js.map +1 -0
  68. package/dist/root.d.ts +89 -0
  69. package/dist/root.d.ts.map +1 -0
  70. package/dist/root.js +401 -0
  71. package/dist/root.js.map +1 -0
  72. package/dist/search.d.ts +19 -0
  73. package/dist/search.d.ts.map +1 -0
  74. package/dist/search.js +31 -0
  75. package/dist/search.js.map +1 -0
  76. package/dist/size.d.ts +12 -0
  77. package/dist/size.d.ts.map +1 -0
  78. package/dist/size.js +20 -0
  79. package/dist/size.js.map +1 -0
  80. package/dist/svg-base.d.ts +33 -0
  81. package/dist/svg-base.d.ts.map +1 -0
  82. package/dist/svg-base.js +89 -0
  83. package/dist/svg-base.js.map +1 -0
  84. package/dist/types.d.ts +61 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/dist/types.js +2 -0
  87. package/dist/types.js.map +1 -0
  88. package/dist/use-explorer-camera.d.ts +56 -0
  89. package/dist/use-explorer-camera.d.ts.map +1 -0
  90. package/dist/use-explorer-camera.js +641 -0
  91. package/dist/use-explorer-camera.js.map +1 -0
  92. package/dist/use-explorer.d.ts +23 -0
  93. package/dist/use-explorer.d.ts.map +1 -0
  94. package/dist/use-explorer.js +26 -0
  95. package/dist/use-explorer.js.map +1 -0
  96. package/dist/validate.d.ts +20 -0
  97. package/dist/validate.d.ts.map +1 -0
  98. package/dist/validate.js +99 -0
  99. package/dist/validate.js.map +1 -0
  100. package/dist/viewport-surface.d.ts +90 -0
  101. package/dist/viewport-surface.d.ts.map +1 -0
  102. package/dist/viewport-surface.js +485 -0
  103. package/dist/viewport-surface.js.map +1 -0
  104. package/dist/visible-set.d.ts +65 -0
  105. package/dist/visible-set.d.ts.map +1 -0
  106. package/dist/visible-set.js +168 -0
  107. package/dist/visible-set.js.map +1 -0
  108. package/package.json +62 -0
  109. package/src/base.ts +67 -0
  110. package/src/camera.ts +206 -0
  111. package/src/context.ts +168 -0
  112. package/src/dagr-explorer.tsx +85 -0
  113. package/src/errors.ts +85 -0
  114. package/src/explorer-details.tsx +156 -0
  115. package/src/explorer-search.tsx +140 -0
  116. package/src/explorer-toolbar.tsx +77 -0
  117. package/src/explorer-trace-toggle.tsx +34 -0
  118. package/src/explorer-viewport.tsx +181 -0
  119. package/src/explorer-views.tsx +61 -0
  120. package/src/index.ts +76 -0
  121. package/src/isomorphic-layout-effect.ts +7 -0
  122. package/src/labels.ts +109 -0
  123. package/src/layout.ts +298 -0
  124. package/src/navigation.ts +51 -0
  125. package/src/root.tsx +544 -0
  126. package/src/search.ts +36 -0
  127. package/src/size.ts +23 -0
  128. package/src/svg-base.tsx +173 -0
  129. package/src/types.ts +69 -0
  130. package/src/use-explorer-camera.ts +675 -0
  131. package/src/use-explorer.ts +32 -0
  132. package/src/validate.ts +173 -0
  133. package/src/viewport-surface.tsx +650 -0
  134. package/src/visible-set.ts +225 -0
  135. package/styles.css +253 -0
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Which nodes get a DOM element, which are left to the base layer, and which
3
+ * edges are in view: the explorer's virtualization, as one pure function.
4
+ *
5
+ * A node's tier comes from its width on screen in CSS pixels, with half-open
6
+ * gates, the rule `@prnt/dagr-render`'s rich nodes use. Below the `summary`
7
+ * gate it is a mark and has NO element: the base layer draws it.
8
+ *
9
+ * **The scan is linear on purpose.** Testing 10,000 boxes costs about half a
10
+ * millisecond per call (0.4 to 0.6 ms measured in `packages/explorer/bench`,
11
+ * the output lists included), well inside a 16 ms frame. The renderer's overlay scans the same way and names a spatial
12
+ * index as the fix if a measurement ever asks for one.
13
+ *
14
+ * Internal to the package. Only the tier types and `ExplorerVisibleSet` are
15
+ * exported from the entry, as types: a base layer is given a visible set.
16
+ */
17
+ import { visibleWorld } from './camera.js';
18
+ export const DEFAULT_TIERS = Object.freeze({ summary: 56, rich: 200 });
19
+ export const DEFAULT_MAX_OVERLAY_NODES = 200;
20
+ export const OVERSCAN = 0.25;
21
+ export function indexLayout(layout) {
22
+ const edgeIds = [];
23
+ const edgeBounds = [];
24
+ for (const [id, points] of layout.routes) {
25
+ edgeIds.push(id);
26
+ if (points.length === 0) {
27
+ edgeBounds.push(null);
28
+ continue;
29
+ }
30
+ let minX = Infinity;
31
+ let minY = Infinity;
32
+ let maxX = -Infinity;
33
+ let maxY = -Infinity;
34
+ for (const p of points) {
35
+ minX = Math.min(minX, p.x);
36
+ minY = Math.min(minY, p.y);
37
+ maxX = Math.max(maxX, p.x);
38
+ maxY = Math.max(maxY, p.y);
39
+ }
40
+ edgeBounds.push({ x: minX, y: minY, width: maxX - minX, height: maxY - minY });
41
+ }
42
+ return {
43
+ nodeIds: [...layout.boxes.keys()],
44
+ nodeBoxes: [...layout.boxes.values()],
45
+ edgeIds,
46
+ edgeBounds,
47
+ };
48
+ }
49
+ /** Inclusive: boxes that only touch count, so a hairline route is not culled. */
50
+ function intersects(a, b) {
51
+ return (a.x <= b.x + b.width &&
52
+ a.x + a.width >= b.x &&
53
+ a.y <= b.y + b.height &&
54
+ a.y + a.height >= b.y);
55
+ }
56
+ export function computeVisibleSet(index, camera, viewport, options = {}) {
57
+ const tiers = options.tiers ?? DEFAULT_TIERS;
58
+ const cap = options.maxOverlayNodes ?? DEFAULT_MAX_OVERLAY_NODES;
59
+ const pinned = new Set(options.pinned ?? []);
60
+ const world = visibleWorld(camera, viewport);
61
+ const view = {
62
+ x: world.x - world.width * OVERSCAN,
63
+ y: world.y - world.height * OVERSCAN,
64
+ width: world.width * (1 + OVERSCAN * 2),
65
+ height: world.height * (1 + OVERSCAN * 2),
66
+ };
67
+ const centerX = world.x + world.width / 2;
68
+ const centerY = world.y + world.height / 2;
69
+ const tierOf = new Map();
70
+ const candidates = [];
71
+ const inView = [];
72
+ index.nodeBoxes.forEach((box, i) => {
73
+ const id = index.nodeIds[i];
74
+ const screenWidth = box.width * camera.scale;
75
+ const natural = screenWidth >= tiers.rich ? 'rich' : screenWidth >= tiers.summary ? 'summary' : null;
76
+ if (pinned.has(id)) {
77
+ tierOf.set(i, natural ?? 'summary');
78
+ return;
79
+ }
80
+ if (!intersects(box, view))
81
+ return;
82
+ inView.push(i);
83
+ if (natural === null)
84
+ return;
85
+ tierOf.set(i, natural);
86
+ const dx = box.x + box.width / 2 - centerX;
87
+ const dy = box.y + box.height / 2 - centerY;
88
+ candidates.push({ i, distance: dx * dx + dy * dy });
89
+ });
90
+ if (candidates.length > cap) {
91
+ candidates.sort((a, b) => a.distance - b.distance ||
92
+ (index.nodeIds[a.i] < index.nodeIds[b.i] ? -1 : 1));
93
+ for (const dropped of candidates.slice(Math.max(0, cap)))
94
+ tierOf.delete(dropped.i);
95
+ }
96
+ const overlay = new Map();
97
+ index.nodeIds.forEach((id, i) => {
98
+ const tier = tierOf.get(i);
99
+ if (tier !== undefined)
100
+ overlay.set(id, tier);
101
+ });
102
+ const baseNodes = inView.filter((i) => !tierOf.has(i)).map((i) => index.nodeIds[i]);
103
+ const edges = [];
104
+ index.edgeBounds.forEach((bounds, i) => {
105
+ if (bounds !== null && intersects(bounds, view))
106
+ edges.push(index.edgeIds[i]);
107
+ });
108
+ return { overlay, baseNodes, edges };
109
+ }
110
+ /**
111
+ * Whether two sets hold the same nodes at the same tiers, and the same marks
112
+ * and edges in the same order. Overlay order is not compared:
113
+ * `computeVisibleSet` always emits the overlay in the index's data order, so
114
+ * two sets over one index cannot differ in order alone.
115
+ */
116
+ export function sameVisibleSet(a, b) {
117
+ if (a === b)
118
+ return true;
119
+ if (a.overlay.size !== b.overlay.size ||
120
+ a.baseNodes.length !== b.baseNodes.length ||
121
+ a.edges.length !== b.edges.length) {
122
+ return false;
123
+ }
124
+ for (const [id, tier] of a.overlay) {
125
+ if (b.overlay.get(id) !== tier)
126
+ return false;
127
+ }
128
+ for (let i = 0; i < a.baseNodes.length; i += 1) {
129
+ if (a.baseNodes[i] !== b.baseNodes[i])
130
+ return false;
131
+ }
132
+ for (let i = 0; i < a.edges.length; i += 1) {
133
+ if (a.edges[i] !== b.edges[i])
134
+ return false;
135
+ }
136
+ return true;
137
+ }
138
+ export function nearestToCenter(index, camera, viewport) {
139
+ const world = visibleWorld(camera, viewport);
140
+ const centerX = world.x + world.width / 2;
141
+ const centerY = world.y + world.height / 2;
142
+ let best = null;
143
+ let bestDistance = Infinity;
144
+ index.nodeBoxes.forEach((box, i) => {
145
+ const dx = box.x + box.width / 2 - centerX;
146
+ const dy = box.y + box.height / 2 - centerY;
147
+ const distance = dx * dx + dy * dy;
148
+ const id = index.nodeIds[i];
149
+ if (distance < bestDistance || (distance === bestDistance && best !== null && id < best)) {
150
+ best = id;
151
+ bestDistance = distance;
152
+ }
153
+ });
154
+ return best;
155
+ }
156
+ export function nodeAtPoint(index, point) {
157
+ for (let i = index.nodeBoxes.length - 1; i >= 0; i -= 1) {
158
+ const box = index.nodeBoxes[i];
159
+ if (point.x >= box.x &&
160
+ point.x <= box.x + box.width &&
161
+ point.y >= box.y &&
162
+ point.y <= box.y + box.height) {
163
+ return index.nodeIds[i];
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ //# sourceMappingURL=visible-set.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"visible-set.js","sourceRoot":"","sources":["../src/visible-set.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAQ3C,MAAM,CAAC,MAAM,aAAa,GAAkB,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC;AACtF,MAAM,CAAC,MAAM,yBAAyB,GAAG,GAAG,CAAC;AAC7C,MAAM,CAAC,MAAM,QAAQ,GAAG,IAAI,CAAC;AAU7B,MAAM,UAAU,WAAW,CAAC,MAAsB;IAChD,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,MAAM,UAAU,GAA2B,EAAE,CAAC;IAC9C,KAAK,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;QACzC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjB,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACxB,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACtB,SAAS;QACX,CAAC;QACD,IAAI,IAAI,GAAG,QAAQ,CAAC;QACpB,IAAI,IAAI,GAAG,QAAQ,CAAC;QACpB,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC;QACrB,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC;QACrB,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;YACvB,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;QAC7B,CAAC;QACD,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,GAAG,IAAI,EAAE,MAAM,EAAE,IAAI,GAAG,IAAI,EAAE,CAAC,CAAC;IACjF,CAAC;IACD,OAAO;QACL,OAAO,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QACjC,SAAS,EAAE,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC;QACrC,OAAO;QACP,UAAU;KACX,CAAC;AACJ,CAAC;AAuBD,iFAAiF;AACjF,SAAS,UAAU,CAAC,CAAc,EAAE,CAAc;IAChD,OAAO,CACL,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK;QACpB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,KAAK,IAAI,CAAC,CAAC,CAAC;QACpB,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM;QACrB,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC,CAAC,CACtB,CAAC;AACJ,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,KAAkB,EAClB,MAAsB,EACtB,QAA8B,EAC9B,UAA6B,EAAE;IAE/B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,aAAa,CAAC;IAC7C,MAAM,GAAG,GAAG,OAAO,CAAC,eAAe,IAAI,yBAAyB,CAAC;IACjE,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC;IAC7C,MAAM,KAAK,GAAG,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC7C,MAAM,IAAI,GAAgB;QACxB,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,GAAG,QAAQ;QACnC,CAAC,EAAE,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,QAAQ;QACpC,KAAK,EAAE,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC;QACvC,MAAM,EAAE,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,GAAG,QAAQ,GAAG,CAAC,CAAC;KAC1C,CAAC;IACF,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IAE3C,MAAM,MAAM,GAAG,IAAI,GAAG,EAAwB,CAAC;IAC/C,MAAM,UAAU,GAAwD,EAAE,CAAC;IAC3E,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE;QACjC,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAW,CAAC;QACtC,MAAM,WAAW,GAAG,GAAG,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC7C,MAAM,OAAO,GACX,WAAW,IAAI,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,WAAW,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC;QACvF,IAAI,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;YACnB,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,IAAI,SAAS,CAAC,CAAC;YACpC,OAAO;QACT,CAAC;QACD,IAAI,CAAC,UAAU,CAAC,GAAG,EAAE,IAAI,CAAC;YAAE,OAAO;QACnC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;QACf,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO;QAC7B,MAAM,CAAC,GAAG,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;QACvB,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,KAAK,GAAG,CAAC,GAAG,OAAO,CAAC;QAC3C,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC;QAC5C,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,QAAQ,EAAE,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC;IACtD,CAAC,CAAC,CAAC;IAEH,IAAI,UAAU,CAAC,MAAM,GAAG,GAAG,EAAE,CAAC;QAC5B,UAAU,CAAC,IAAI,CACb,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CACP,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ;YACvB,CAAE,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAY,GAAI,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAY,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAC7E,CAAC;QACF,KAAK,MAAM,OAAO,IAAI,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;YAAE,MAAM,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IACrF,CAAC;IAED,MAAM,OAAO,GAAG,IAAI,GAAG,EAAwB,CAAC;IAChD,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE;QAC9B,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QAC3B,IAAI,IAAI,KAAK,SAAS;YAAE,OAAO,CAAC,GAAG,CAAC,EAAE,EAAE,IAAI,CAAC,CAAC;IAChD,CAAC,CAAC,CAAC;IACH,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAW,CAAC,CAAC;IAC9F,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE;QACrC,IAAI,MAAM,KAAK,IAAI,IAAI,UAAU,CAAC,MAAM,EAAE,IAAI,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAW,CAAC,CAAC;IAC1F,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC;AACvC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,cAAc,CAAC,CAAqB,EAAE,CAAqB;IACzE,IAAI,CAAC,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IACzB,IACE,CAAC,CAAC,OAAO,CAAC,IAAI,KAAK,CAAC,CAAC,OAAO,CAAC,IAAI;QACjC,CAAC,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,CAAC,SAAS,CAAC,MAAM;QACzC,CAAC,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,KAAK,CAAC,MAAM,EACjC,CAAC;QACD,OAAO,KAAK,CAAC;IACf,CAAC;IACD,KAAK,MAAM,CAAC,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAC;QACnC,IAAI,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,KAAK,IAAI;YAAE,OAAO,KAAK,CAAC;IAC/C,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,SAAS,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC/C,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;IACtD,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC3C,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC;YAAE,OAAO,KAAK,CAAC;IAC9C,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,eAAe,CAC7B,KAAkB,EAClB,MAAsB,EACtB,QAA8B;IAE9B,MAAM,KAAK,GAAG,YAAY,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;IAC7C,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC;IAC1C,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC;IAC3C,IAAI,IAAI,GAAkB,IAAI,CAAC;IAC/B,IAAI,YAAY,GAAG,QAAQ,CAAC;IAC5B,KAAK,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,CAAC,EAAE,EAAE;QACjC,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,KAAK,GAAG,CAAC,GAAG,OAAO,CAAC;QAC3C,MAAM,EAAE,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC;QAC5C,MAAM,QAAQ,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC;QACnC,MAAM,EAAE,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAW,CAAC;QACtC,IAAI,QAAQ,GAAG,YAAY,IAAI,CAAC,QAAQ,KAAK,YAAY,IAAI,IAAI,KAAK,IAAI,IAAI,EAAE,GAAG,IAAI,CAAC,EAAE,CAAC;YACzF,IAAI,GAAG,EAAE,CAAC;YACV,YAAY,GAAG,QAAQ,CAAC;QAC1B,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,IAAI,CAAC;AACd,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,KAAkB,EAAE,KAAW;IACzD,KAAK,IAAI,CAAC,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QACxD,MAAM,GAAG,GAAG,KAAK,CAAC,SAAS,CAAC,CAAC,CAAgB,CAAC;QAC9C,IACE,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;YAChB,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,KAAK;YAC5B,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC;YAChB,KAAK,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,GAAG,GAAG,CAAC,MAAM,EAC7B,CAAC;YACD,OAAO,KAAK,CAAC,OAAO,CAAC,CAAC,CAAW,CAAC;QACpC,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@prnt/dagr-explorer",
3
+ "version": "0.1.3",
4
+ "description": "An interactive graph explorer for Dagr: views, search, connection tracing, groups and a details drawer, with virtualized node content.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "./styles.css": "./styles.css"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "styles.css",
22
+ "CHANGELOG.md",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "sideEffects": [
30
+ "*.css"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/prnt-design/dagr.git",
35
+ "directory": "packages/explorer"
36
+ },
37
+ "dependencies": {
38
+ "@prnt/dagr-graph": "^0.1.3",
39
+ "@prnt/dagr-render": "^0.1.3",
40
+ "@prnt/dagr-layout": "^0.1.3"
41
+ },
42
+ "peerDependencies": {
43
+ "react": ">=18.2.0 <20.0.0",
44
+ "react-dom": ">=18.2.0 <20.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/react": "^19.2.17",
48
+ "@types/react-dom": "^19.2.3",
49
+ "jsdom": "^30.0.1",
50
+ "react": "^19.2.8",
51
+ "react-18": "npm:react@^18.3.1",
52
+ "react-dom": "^19.2.8",
53
+ "react-dom-18": "npm:react-dom@^18.3.1"
54
+ },
55
+ "scripts": {
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "vitest run && vitest run --config vitest.react18.config.ts",
58
+ "test:react18": "vitest run --config vitest.react18.config.ts",
59
+ "build": "tsc -p tsconfig.build.json",
60
+ "bench": "vitest bench --run --outputJson bench-report.json"
61
+ }
62
+ }
package/src/base.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The base-layer seam: what draws every node the overlay does not mount.
3
+ *
4
+ * The viewport renders the overlay, capped, as DOM. Everything else in view
5
+ * (nodes as marks, edges, group outlines) is the base layer's, and the base
6
+ * is swappable: the SVG base is the default, and a native renderer can take
7
+ * its place for graphs past the SVG ceiling.
8
+ *
9
+ * `space` is the one fact the viewport needs about a base. A `'plane'` base
10
+ * renders inside the transformed plane in world coordinates, so a camera
11
+ * frame costs it nothing. A `'viewport'` base sits outside the plane and
12
+ * drives its own camera from `camera`, which tells it of every frame in the
13
+ * same frame the overlay moves, so the two never drift apart.
14
+ *
15
+ * **Experimental.** These types are exported, and may change until the
16
+ * native base lands and confirms or corrects them: a seam with one
17
+ * implementation is a guess.
18
+ */
19
+
20
+ import type { ComponentType } from 'react';
21
+ import type { ExplorerCamera } from './camera.js';
22
+ import type { ExplorerLayout } from './layout.js';
23
+ import type { ExplorerEdge, ExplorerNode, ExplorerView } from './types.js';
24
+ import type { ExplorerVisibleSet } from './visible-set.js';
25
+
26
+ /** What the base draws emphasized. Experimental, like the seam. */
27
+ export interface ExplorerEmphasis {
28
+ readonly selectedId: string | null;
29
+ /** Node ids drawn dimmed. Edges with a dimmed end are dimmed. */
30
+ readonly dimmed: ReadonlySet<string>;
31
+ }
32
+
33
+ /** The camera on screen, and every frame of it. Experimental, like the seam. */
34
+ export interface ExplorerCameraSource {
35
+ /** The camera on screen, or `null` before the first fit. */
36
+ get(): ExplorerCamera | null;
37
+ /**
38
+ * Calls `listener` on each drawn frame, right after the plane's transform
39
+ * is written. Returns the unsubscribe.
40
+ */
41
+ subscribe(listener: (camera: ExplorerCamera) => void): () => void;
42
+ }
43
+
44
+ /** What a base layer is given. Experimental: may change until a native base confirms it. */
45
+ export interface ExplorerBaseProps<N extends ExplorerNode, E extends ExplorerEdge> {
46
+ /**
47
+ * The view as given, less its layout options: `nodeSize` there is a
48
+ * function of `N`, which a base typed over the base node cannot accept,
49
+ * and the layout is already done.
50
+ */
51
+ readonly view: Omit<ExplorerView<N, E>, 'layout'>;
52
+ readonly layout: ExplorerLayout;
53
+ readonly visible: ExplorerVisibleSet;
54
+ readonly emphasis: ExplorerEmphasis;
55
+ readonly camera: ExplorerCameraSource;
56
+ }
57
+
58
+ /**
59
+ * A base layer: what draws every node the overlay does not mount, every
60
+ * edge and every group outline. Experimental: may change until a native
61
+ * base confirms it.
62
+ */
63
+ export interface ExplorerBase {
64
+ readonly Layer: ComponentType<ExplorerBaseProps<ExplorerNode, ExplorerEdge>>;
65
+ /** 'plane': rendered inside the transformed plane. 'viewport': handles the camera itself. */
66
+ readonly space: 'plane' | 'viewport';
67
+ }
package/src/camera.ts ADDED
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The camera's arithmetic, with no DOM and no clock.
3
+ *
4
+ * A camera is the plane's CSS transform: `translate(x, y) scale(scale)`, in
5
+ * CSS pixels, over a world that is y-down. Everything here is a pure function
6
+ * from a camera to a camera, so the hook that owns the animation frame holds
7
+ * no arithmetic of its own and all of it is testable in Node.
8
+ *
9
+ * **Functions return the camera that was ASKED for, not the one allowed.**
10
+ * `CameraLimits.constrain` is the one place a camera is clamped, and the
11
+ * caller decides when: a target is constrained when it is set, and the eased
12
+ * camera again when it is drawn, so a flight can cross a sparse gap without
13
+ * being trapped against the nearest node on the way.
14
+ *
15
+ * Limits come from `Camera2D` in `@prnt/dagr-render/core`, which speaks a
16
+ * y-up world with a center and a zoom. The conversion to and from this
17
+ * module's top-left, y-down camera lives in `createCameraLimits` and nowhere
18
+ * else.
19
+ *
20
+ * Internal to the package. Only the `ExplorerCamera` type is exported from
21
+ * the entry, because `ExplorerCameraSource` hands one out.
22
+ */
23
+
24
+ import { Camera2D, fitZoom } from '@prnt/dagr-render/core';
25
+ import type { ExplorerBox, ExplorerLayout } from './layout.js';
26
+ import type { Vec2 } from '@prnt/dagr-render/core';
27
+
28
+ export interface ExplorerCamera {
29
+ readonly x: number;
30
+ readonly y: number;
31
+ readonly scale: number;
32
+ }
33
+ export interface ExplorerViewportSize {
34
+ readonly width: number;
35
+ readonly height: number;
36
+ }
37
+ export interface CameraLimits {
38
+ readonly minScale: number;
39
+ readonly maxScale: number;
40
+ /**
41
+ * Throws a `RangeError` for a camera whose `scale` is not a positive finite
42
+ * number or whose `x` or `y` is not finite, because `Camera2D` validates
43
+ * what it is given. Callers hold cameras that came from these functions.
44
+ */
45
+ constrain(camera: ExplorerCamera): ExplorerCamera;
46
+ }
47
+
48
+ export const CONTENT_PADDING = 0.05;
49
+ export const FOCUS_MARGIN = 24;
50
+ export const REVEAL_MARGIN = 12;
51
+ export const EASE_MS = 55;
52
+
53
+ export function createCameraLimits(
54
+ layout: ExplorerLayout,
55
+ viewport: ExplorerViewportSize,
56
+ ): CameraLimits | null {
57
+ if (!(viewport.width > 0) || !(viewport.height > 0) || !(layout.width > 0) || !(layout.height > 0)) {
58
+ return null;
59
+ }
60
+ const limiter = new Camera2D({
61
+ viewport: { width: viewport.width, height: viewport.height, devicePixelRatio: 1 },
62
+ });
63
+ let detail = { width: 160, height: 80 };
64
+ let best = 0;
65
+ const regions = [];
66
+ for (const box of layout.boxes.values()) {
67
+ const zoom = fitZoom({ minX: 0, minY: 0, maxX: box.width, maxY: box.height }, limiter.viewport);
68
+ if (zoom > best) {
69
+ best = zoom;
70
+ detail = { width: box.width, height: box.height };
71
+ }
72
+ regions.push({
73
+ minX: box.x,
74
+ maxX: box.x + box.width,
75
+ minY: -box.y - box.height,
76
+ maxY: -box.y,
77
+ });
78
+ }
79
+ limiter.setContentBounds(
80
+ { minX: 0, maxX: layout.width, minY: -layout.height, maxY: 0 },
81
+ detail,
82
+ CONTENT_PADDING,
83
+ regions,
84
+ );
85
+ return {
86
+ minScale: limiter.minZoom,
87
+ maxScale: limiter.maxZoom,
88
+ constrain(camera) {
89
+ limiter.setZoom(camera.scale);
90
+ limiter.setCenter({
91
+ x: (viewport.width / 2 - camera.x) / limiter.zoom,
92
+ y: -(viewport.height / 2 - camera.y) / limiter.zoom,
93
+ });
94
+ return {
95
+ x: viewport.width / 2 - limiter.center.x * limiter.zoom,
96
+ y: viewport.height / 2 + limiter.center.y * limiter.zoom,
97
+ scale: limiter.zoom,
98
+ };
99
+ },
100
+ };
101
+ }
102
+
103
+ export function fitCamera(
104
+ layout: ExplorerLayout,
105
+ viewport: ExplorerViewportSize,
106
+ limits: CameraLimits,
107
+ ): ExplorerCamera {
108
+ const scale = limits.minScale;
109
+ return {
110
+ x: (viewport.width - layout.width * scale) / 2,
111
+ y: (viewport.height - layout.height * scale) / 2,
112
+ scale,
113
+ };
114
+ }
115
+
116
+ /** Divides by `camera.scale`: expects a positive scale, as every camera from `fitCamera` and `constrain` has. */
117
+ export function zoomCamera(
118
+ camera: ExplorerCamera,
119
+ factor: number,
120
+ anchor: Vec2,
121
+ limits: CameraLimits,
122
+ ): ExplorerCamera {
123
+ const scale = Math.max(limits.minScale, Math.min(limits.maxScale, camera.scale * factor));
124
+ const ratio = scale / camera.scale;
125
+ return {
126
+ x: anchor.x - (anchor.x - camera.x) * ratio,
127
+ y: anchor.y - (anchor.y - camera.y) * ratio,
128
+ scale,
129
+ };
130
+ }
131
+
132
+ export function panCamera(camera: ExplorerCamera, dx: number, dy: number): ExplorerCamera {
133
+ return { x: camera.x + dx, y: camera.y + dy, scale: camera.scale };
134
+ }
135
+
136
+ export function focusCamera(
137
+ box: ExplorerBox,
138
+ viewport: ExplorerViewportSize,
139
+ limits: CameraLimits,
140
+ ): ExplorerCamera {
141
+ const fill = Math.min(
142
+ (viewport.width - FOCUS_MARGIN * 2) / box.width,
143
+ (viewport.height - FOCUS_MARGIN * 2) / box.height,
144
+ );
145
+ const scale = Math.max(limits.minScale, Math.min(limits.maxScale, fill));
146
+ return {
147
+ x: viewport.width / 2 - (box.x + box.width / 2) * scale,
148
+ y: viewport.height / 2 - (box.y + box.height / 2) * scale,
149
+ scale,
150
+ };
151
+ }
152
+
153
+ export function revealCamera(
154
+ camera: ExplorerCamera,
155
+ box: ExplorerBox,
156
+ viewport: ExplorerViewportSize,
157
+ ): ExplorerCamera {
158
+ const axis = (offset: number, start: number, size: number, extent: number): number => {
159
+ const lo = offset + start * camera.scale;
160
+ const hi = lo + size * camera.scale;
161
+ if (hi - lo > extent - REVEAL_MARGIN * 2) return offset + (extent / 2 - (lo + hi) / 2);
162
+ if (lo < REVEAL_MARGIN) return offset + (REVEAL_MARGIN - lo);
163
+ if (hi > extent - REVEAL_MARGIN) return offset + (extent - REVEAL_MARGIN - hi);
164
+ return offset;
165
+ };
166
+ return {
167
+ x: axis(camera.x, box.x, box.width, viewport.width),
168
+ y: axis(camera.y, box.y, box.height, viewport.height),
169
+ scale: camera.scale,
170
+ };
171
+ }
172
+
173
+ /** Divides by `camera.scale`: expects a positive scale, as every camera from `fitCamera` and `constrain` has. */
174
+ export function visibleWorld(camera: ExplorerCamera, viewport: ExplorerViewportSize): ExplorerBox {
175
+ return {
176
+ x: -camera.x / camera.scale,
177
+ y: -camera.y / camera.scale,
178
+ width: viewport.width / camera.scale,
179
+ height: viewport.height / camera.scale,
180
+ };
181
+ }
182
+
183
+ /** Divides by `camera.scale`: expects a positive scale, as every camera from `fitCamera` and `constrain` has. */
184
+ export function screenToWorld(camera: ExplorerCamera, point: Vec2): Vec2 {
185
+ return { x: (point.x - camera.x) / camera.scale, y: (point.y - camera.y) / camera.scale };
186
+ }
187
+
188
+ export function easeCamera(
189
+ current: ExplorerCamera,
190
+ target: ExplorerCamera,
191
+ elapsedMs: number,
192
+ ): ExplorerCamera {
193
+ const alpha = 1 - Math.exp(-Math.max(0, Math.min(64, elapsedMs)) / EASE_MS);
194
+ return {
195
+ x: current.x + (target.x - current.x) * alpha,
196
+ y: current.y + (target.y - current.y) * alpha,
197
+ scale: current.scale + (target.scale - current.scale) * alpha,
198
+ };
199
+ }
200
+
201
+ export function cameraSettled(current: ExplorerCamera, target: ExplorerCamera): boolean {
202
+ return (
203
+ Math.abs(current.x - target.x) + Math.abs(current.y - target.y) < 0.05 &&
204
+ Math.abs(current.scale - target.scale) < 0.0001
205
+ );
206
+ }
package/src/context.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The contexts the parts talk through, and the public shapes they carry.
3
+ *
4
+ * `ExplorerRoot` provides two. The api context holds only the stable
5
+ * `ExplorerApi`, and its value never changes, so a component that only calls
6
+ * methods (`useExplorerApi()`) never re-renders for a keystroke. The main
7
+ * context holds two things:
8
+ * the state a host may read and the methods it may call (`useExplorer()`
9
+ * returns exactly that), and a few internals only the built-in parts use to
10
+ * wire themselves to the root: the viewport's registration, its camera
11
+ * controls, and the elements focus is restored to.
12
+ *
13
+ * **The type parameters are a claim, not a check.** A context erases them, so
14
+ * the state is stored over the base node and edge types and each part casts
15
+ * to the `N` and `E` it was given. See the spec's Composition section.
16
+ */
17
+
18
+ import { createContext, useContext } from 'react';
19
+ import type { MutableRefObject } from 'react';
20
+ import type { ExplorerCameraSource } from './base.js';
21
+ import type { ExplorerCamera } from './camera.js';
22
+ import { ExplorerContextError } from './errors.js';
23
+ import type { ExplorerLabels } from './labels.js';
24
+ import type { ExplorerLayout } from './layout.js';
25
+ import type { ExplorerEdge, ExplorerNode, ExplorerView } from './types.js';
26
+ import type { ExplorerCameraControls } from './use-explorer-camera.js';
27
+
28
+ /**
29
+ * Everything a host can do to an explorer. `ExplorerRoot`'s `apiRef` and
30
+ * `useExplorer()` hand out the same functions, stable for the root's life.
31
+ *
32
+ * Under a controlled `viewId` or `selectedId`, a method that would change it
33
+ * calls `onViewChange` or `onSelectedChange` and changes nothing on screen
34
+ * until the prop does. An id the active view lacks is ignored.
35
+ */
36
+ export interface ExplorerApi {
37
+ /** Fits the whole view. A no-op before a viewport has a size. */
38
+ fit(): void;
39
+ /** Zooms about the viewport center. */
40
+ zoomBy(factor: number): void;
41
+ /** Flies the camera to fit the node. */
42
+ focusNode(id: string): void;
43
+ /** Pans the least distance that brings the node into view, at the current zoom. */
44
+ reveal(id: string): void;
45
+ /** Sets the current node, or clears it. Does not open the drawer. */
46
+ select(id: string | null): void;
47
+ /**
48
+ * Selects the node and opens the drawer on it. `trigger` is the element
49
+ * focus returns to when the drawer closes. Without one, the element that
50
+ * has focus is recorded, unless the drawer is already open, in which case
51
+ * the opener it has is kept: that is how a connection in the drawer
52
+ * inspects its neighbor and `Escape` still returns to the first opener.
53
+ */
54
+ inspect(id: string, trigger?: HTMLElement | null): void;
55
+ closeDetails(): void;
56
+ /** Switches view. Resets the query, trace, drawer and camera. */
57
+ selectView(id: string): void;
58
+ setQuery(query: string): void;
59
+ setTrace(on: boolean): void;
60
+ }
61
+
62
+ /** What `useExplorer()` returns: the explorer's current state, and its methods. */
63
+ export interface ExplorerState<N extends ExplorerNode = ExplorerNode, E extends ExplorerEdge = ExplorerEdge>
64
+ extends ExplorerApi {
65
+ /** The root's `label`, the accessible name the parts derive theirs from. */
66
+ readonly label: string;
67
+ readonly labels: ExplorerLabels;
68
+ readonly views: readonly ExplorerView<N, E>[];
69
+ /** `null` only when there are no views. */
70
+ readonly activeView: ExplorerView<N, E> | null;
71
+ /** The active view, placed. `null` only when there are no views. */
72
+ readonly layout: ExplorerLayout | null;
73
+ /** The current node, or `null`. Never an id the active view lacks. */
74
+ readonly selectedId: string | null;
75
+ readonly selectedNode: N | null;
76
+ readonly query: string;
77
+ /** The nodes the query matches, in data order. Empty for a blank query. */
78
+ readonly matches: readonly N[];
79
+ readonly trace: boolean;
80
+ /** Whether the drawer is showing the selected node. */
81
+ readonly detailsOpen: boolean;
82
+ /** Node ids drawn dimmed: by the query, by trace, or by both. */
83
+ readonly dimmed: ReadonlySet<string>;
84
+ /** The camera on screen and every frame of it, for a host's own zoom readout. */
85
+ readonly camera: ExplorerCameraSource;
86
+ }
87
+
88
+ /**
89
+ * The camera source the root hands out before and after a viewport exists:
90
+ * it forwards to whichever viewport is attached, and stays the same object
91
+ * across a view switch, which remounts the viewport.
92
+ */
93
+ export interface CameraHub extends ExplorerCameraSource {
94
+ /** Attaches a viewport's source. Returns the detach. */
95
+ attach(source: ExplorerCameraSource): () => void;
96
+ }
97
+
98
+ export function createCameraHub(): CameraHub {
99
+ let attached: ExplorerCameraSource | null = null;
100
+ const listeners = new Set<(camera: ExplorerCamera) => void>();
101
+ const emit = (camera: ExplorerCamera): void => {
102
+ for (const listener of [...listeners]) listener(camera);
103
+ };
104
+ return {
105
+ get: () => attached?.get() ?? null,
106
+ subscribe(listener) {
107
+ // Wrapped, so one listener subscribed twice is two subscriptions.
108
+ const own = (camera: ExplorerCamera): void => listener(camera);
109
+ listeners.add(own);
110
+ return () => {
111
+ listeners.delete(own);
112
+ };
113
+ },
114
+ attach(source) {
115
+ attached = source;
116
+ const off = source.subscribe(emit);
117
+ // The viewport drew its first frame before it could be attached, so a
118
+ // readout that subscribed early would otherwise wait for the next one.
119
+ const now = source.get();
120
+ if (now !== null) emit(now);
121
+ return () => {
122
+ off();
123
+ if (attached === source) attached = null;
124
+ };
125
+ },
126
+ };
127
+ }
128
+
129
+ /** What only the built-in parts use. Stable for the root's life. */
130
+ export interface ExplorerInternals {
131
+ /** Called from the viewport's effect. Throws `SECOND_VIEWPORT` for a second. */
132
+ registerViewport(): () => void;
133
+ readonly controlsRef: MutableRefObject<ExplorerCameraControls | null>;
134
+ readonly camera: CameraHub;
135
+ /** Where focus goes when the drawer closes and its opener is gone. */
136
+ readonly searchInputRef: MutableRefObject<HTMLInputElement | null>;
137
+ }
138
+
139
+ export interface ExplorerContextValue {
140
+ readonly state: ExplorerState;
141
+ readonly internals: ExplorerInternals;
142
+ }
143
+
144
+ export const ExplorerContext = createContext<ExplorerContextValue | null>(null);
145
+
146
+ /** The root's context, or `OUTSIDE_EXPLORER` naming `part`. */
147
+ export function useExplorerContext(part: string): ExplorerContextValue {
148
+ const value = useContext(ExplorerContext);
149
+ if (value === null) {
150
+ throw new ExplorerContextError(
151
+ 'OUTSIDE_EXPLORER',
152
+ `${part} must be used inside an ExplorerRoot`,
153
+ );
154
+ }
155
+ return value;
156
+ }
157
+
158
+ /** The root's stable methods alone. Its value is the same object for the root's life. */
159
+ export const ExplorerApiContext = createContext<ExplorerApi | null>(null);
160
+
161
+ /** The root's stable methods, or `OUTSIDE_EXPLORER` naming `part`. */
162
+ export function useExplorerApiContext(part: string): ExplorerApi {
163
+ const value = useContext(ExplorerApiContext);
164
+ if (value === null) {
165
+ throw new ExplorerContextError('OUTSIDE_EXPLORER', `${part} must be used inside an ExplorerRoot`);
166
+ }
167
+ return value;
168
+ }