@graview/react 0.1.28 → 0.1.30

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 (138) hide show
  1. package/dist/affordances.d.ts +2 -1
  2. package/dist/affordances.d.ts.map +1 -1
  3. package/dist/affordances.js +2 -1
  4. package/dist/affordances.js.map +1 -1
  5. package/dist/beside-the-map.d.ts +13 -0
  6. package/dist/beside-the-map.d.ts.map +1 -0
  7. package/dist/beside-the-map.js +14 -0
  8. package/dist/beside-the-map.js.map +1 -0
  9. package/dist/channels.d.ts +2 -1
  10. package/dist/channels.d.ts.map +1 -1
  11. package/dist/channels.js +121 -22
  12. package/dist/channels.js.map +1 -1
  13. package/dist/connectors.d.ts +27 -2
  14. package/dist/connectors.d.ts.map +1 -1
  15. package/dist/connectors.js +144 -23
  16. package/dist/connectors.js.map +1 -1
  17. package/dist/context.d.ts +91 -2
  18. package/dist/context.d.ts.map +1 -1
  19. package/dist/context.js +98 -16
  20. package/dist/context.js.map +1 -1
  21. package/dist/drawing.d.ts +2 -0
  22. package/dist/drawing.d.ts.map +1 -1
  23. package/dist/drawing.js +2 -0
  24. package/dist/drawing.js.map +1 -1
  25. package/dist/drawn.d.ts +1 -1
  26. package/dist/drawn.d.ts.map +1 -1
  27. package/dist/drawn.js +1 -1
  28. package/dist/drawn.js.map +1 -1
  29. package/dist/edge-signs.d.ts +12 -2
  30. package/dist/edge-signs.d.ts.map +1 -1
  31. package/dist/edge-signs.js +95 -9
  32. package/dist/edge-signs.js.map +1 -1
  33. package/dist/go.d.ts +28 -10
  34. package/dist/go.d.ts.map +1 -1
  35. package/dist/go.js +14 -5
  36. package/dist/go.js.map +1 -1
  37. package/dist/index.d.ts +11 -2
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +8 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/last-visit.d.ts +18 -0
  42. package/dist/last-visit.d.ts.map +1 -0
  43. package/dist/last-visit.js +79 -0
  44. package/dist/last-visit.js.map +1 -0
  45. package/dist/linked-reads.d.ts +3 -0
  46. package/dist/linked-reads.d.ts.map +1 -0
  47. package/dist/linked-reads.js +50 -0
  48. package/dist/linked-reads.js.map +1 -0
  49. package/dist/map-choice.d.ts +55 -0
  50. package/dist/map-choice.d.ts.map +1 -0
  51. package/dist/map-choice.js +73 -0
  52. package/dist/map-choice.js.map +1 -0
  53. package/dist/menu-sections.d.ts +67 -0
  54. package/dist/menu-sections.d.ts.map +1 -0
  55. package/dist/menu-sections.js +35 -0
  56. package/dist/menu-sections.js.map +1 -0
  57. package/dist/past.d.ts +58 -0
  58. package/dist/past.d.ts.map +1 -0
  59. package/dist/past.js +70 -0
  60. package/dist/past.js.map +1 -0
  61. package/dist/plots.d.ts +30 -24
  62. package/dist/plots.d.ts.map +1 -1
  63. package/dist/plots.js +168 -149
  64. package/dist/plots.js.map +1 -1
  65. package/dist/popover.d.ts +9 -1
  66. package/dist/popover.d.ts.map +1 -1
  67. package/dist/popover.js +3 -1
  68. package/dist/popover.js.map +1 -1
  69. package/dist/provider.d.ts +6 -2
  70. package/dist/provider.d.ts.map +1 -1
  71. package/dist/provider.js +5 -1
  72. package/dist/provider.js.map +1 -1
  73. package/dist/record-links-layer.d.ts +80 -0
  74. package/dist/record-links-layer.d.ts.map +1 -0
  75. package/dist/record-links-layer.js +127 -0
  76. package/dist/record-links-layer.js.map +1 -0
  77. package/dist/record-links.d.ts +58 -0
  78. package/dist/record-links.d.ts.map +1 -0
  79. package/dist/record-links.js +72 -0
  80. package/dist/record-links.js.map +1 -0
  81. package/dist/resolved-view.d.ts.map +1 -1
  82. package/dist/resolved-view.js +4 -0
  83. package/dist/resolved-view.js.map +1 -1
  84. package/dist/ribbon-width.d.ts +8 -0
  85. package/dist/ribbon-width.d.ts.map +1 -0
  86. package/dist/ribbon-width.js +15 -0
  87. package/dist/ribbon-width.js.map +1 -0
  88. package/dist/ribbons-layer.d.ts +56 -0
  89. package/dist/ribbons-layer.d.ts.map +1 -0
  90. package/dist/ribbons-layer.js +167 -0
  91. package/dist/ribbons-layer.js.map +1 -0
  92. package/dist/ribbons.d.ts +70 -0
  93. package/dist/ribbons.d.ts.map +1 -0
  94. package/dist/ribbons.js +400 -0
  95. package/dist/ribbons.js.map +1 -0
  96. package/dist/routes.d.ts +6 -0
  97. package/dist/routes.d.ts.map +1 -1
  98. package/dist/routes.js +8 -0
  99. package/dist/routes.js.map +1 -1
  100. package/dist/scene-camera.d.ts.map +1 -1
  101. package/dist/scene-camera.js +10 -4
  102. package/dist/scene-camera.js.map +1 -1
  103. package/dist/scene-covered.d.ts +8 -0
  104. package/dist/scene-covered.d.ts.map +1 -0
  105. package/dist/scene-covered.js +118 -0
  106. package/dist/scene-covered.js.map +1 -0
  107. package/dist/scene-hand.d.ts +15 -0
  108. package/dist/scene-hand.d.ts.map +1 -1
  109. package/dist/scene-hand.js +45 -1
  110. package/dist/scene-hand.js.map +1 -1
  111. package/dist/scene-lines.d.ts +15 -2
  112. package/dist/scene-lines.d.ts.map +1 -1
  113. package/dist/scene-lines.js +305 -86
  114. package/dist/scene-lines.js.map +1 -1
  115. package/dist/scene-options.d.ts +10 -3
  116. package/dist/scene-options.d.ts.map +1 -1
  117. package/dist/scene-options.js +26 -9
  118. package/dist/scene-options.js.map +1 -1
  119. package/dist/scene-root.d.ts +7 -0
  120. package/dist/scene-root.d.ts.map +1 -1
  121. package/dist/scene-root.js +224 -158
  122. package/dist/scene-root.js.map +1 -1
  123. package/dist/tie-labels.d.ts +83 -0
  124. package/dist/tie-labels.d.ts.map +1 -0
  125. package/dist/tie-labels.js +98 -0
  126. package/dist/tie-labels.js.map +1 -0
  127. package/dist/view-host.d.ts.map +1 -1
  128. package/dist/view-host.js +7 -2
  129. package/dist/view-host.js.map +1 -1
  130. package/dist/view-registry.d.ts +5 -0
  131. package/dist/view-registry.d.ts.map +1 -1
  132. package/dist/view-registry.js +2 -6
  133. package/dist/view-registry.js.map +1 -1
  134. package/dist/where-drawn.d.ts +3 -1
  135. package/dist/where-drawn.d.ts.map +1 -1
  136. package/dist/where-drawn.js +10 -16
  137. package/dist/where-drawn.js.map +1 -1
  138. package/package.json +5 -5
@@ -1,14 +1,22 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { humanizeField, SCENE_LAYERS } from "@graview/core";
3
- import { edgeSelectionId, edgeOfSelection } from "@graview/layout";
4
- import { CONNECTOR_DASH, connectorStroke, styleFor } from "@graview/render";
5
- import { useMemo, useLayoutEffect, useState } from "react";
2
+ import { counted, humanizeField, labelOf, SCENE_LAYERS } from "@graview/core";
3
+ import { edgeWords } from "@graview/core/words";
4
+ import { provenanceWords, provisionalLinks } from "@graview/core/provenance";
5
+ import { edgeOfSelection } from "@graview/layout";
6
+ import { edgeSelectionId } from "@graview/layout/view-moves";
7
+ import { CONNECTOR_DASH, PROVISIONAL_DASH, PROVISIONAL_OPACITY, connectorStroke, styleFor } from "@graview/render";
8
+ import { Suspense, useEffect, useMemo, useLayoutEffect, useRef, useState } from "react";
6
9
  import { kitConnector, useKit } from "./kit.js";
7
- import { orthogonalPoints, polylineD, routePoint, routedQuadratic } from "./routes.js";
8
- import { Connectors, connectorStrands, tieRoute } from "./connectors.js";
10
+ import { orthogonalPoints, polylineD, roundedPolylineD, routePoint, routedQuadratic } from "./routes.js";
11
+ import { channelRoute } from "./channels.js";
12
+ import { clipQuadratic, Connectors, connectorStrands, movedBy, tieRoute } from "./connectors.js";
9
13
  import { drawnBox, measureVisible, visibleRect } from "./where-drawn.js";
10
14
  import { captionRuns } from "./captions.js";
11
15
  import { railInset } from "./rails.js";
16
+ import { placeTieLabels, tiesReached } from "./tie-labels.js";
17
+ import { retryingImport } from "@graview/core/retry";
18
+ import { lazyModule } from "./lazy-part.js";
19
+ import { mapChoice } from "./map-choice.js";
12
20
  /**
13
21
  * EVERY LINE IN THE SCENE, measured against the DOM it is drawn over.
14
22
  *
@@ -32,7 +40,16 @@ import { railInset } from "./rails.js";
32
40
  * center, so selecting the session lit three of its four drills and left
33
41
  * the fourth to a faint line from nowhere.
34
42
  */
35
- export function Lines({ frame, width, height, scheme, overview, selection, emphasis, stageRef, store, graphNodes, onPickEdge, liveOf, holding = false, }) {
43
+ /**
44
+ * THE LINKS STILL PROVISIONAL, as the lines read them (FR-172): each by its
45
+ * selection id, with the sentence its hover says — who made it and what it
46
+ * was based on. Undefined when there are none, which is nearly always.
47
+ */
48
+ export function provisionalLines(store) {
49
+ const links = provisionalLinks(store);
50
+ return links.length > 0 ? new Map(links.map((link) => [edgeSelectionId(link.edge.kind, link.edge.from, link.edge.to), provenanceWords(link)])) : undefined;
51
+ }
52
+ export function Lines({ frame, width, height, scheme, overview, selection, emphasis, stageRef, store, graphNodes, onPickEdge, liveOf, holding = false, provisional, focusId = null, beside = false, }) {
36
53
  const [asked, remeasure] = useState(0);
37
54
  /*
38
55
  * MEASURED ONCE, AFTER THE DOM IS THERE. Measuring during render read the
@@ -51,11 +68,35 @@ export function Lines({ frame, width, height, scheme, overview, selection, empha
51
68
  * with the world, which is not a new frame, so they follow the hand.
52
69
  */
53
70
  const moving = frame.t < 1;
71
+ /*
72
+ * THE CAPTIONS ARE PLACED IN THE SAME COMMIT, measured from the same
73
+ * boxes, so the first measurement can come before them: a road along a
74
+ * gutter went behind words that were not there yet. Where they stood is
75
+ * kept with each measurement, and a frame later, if they stand elsewhere,
76
+ * the lines are measured again.
77
+ */
78
+ const captionsAt = useRef("");
79
+ /* Where the layout had every card when the lines were measured: a held card's lines move by what it has moved since. */
80
+ const measuredAt = useRef([]);
81
+ const captionsNow = () => [...(stageRef.current?.querySelectorAll("[data-graview-relation] > span") ?? [])].map((el) => { const r = el.getBoundingClientRect(); return `${Math.round(r.left)},${Math.round(r.top)},${Math.round(r.width)}`; }).join(" ");
54
82
  useLayoutEffect(() => {
55
83
  if (moving || holding)
56
84
  return;
85
+ captionsAt.current = captionsNow();
86
+ measuredAt.current = frame.nodes;
57
87
  setMeasured(connectorStrands(frame.nodes, frame.connectors, stageRef.current, overview, scheme));
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
58
89
  }, [frame, moving, holding, selection, overview, graphNodes, scheme, asked, stageRef]);
90
+ useEffect(() => {
91
+ if (moving || holding || overview || typeof requestAnimationFrame === "undefined")
92
+ return;
93
+ const later = requestAnimationFrame(() => {
94
+ if (captionsNow() !== captionsAt.current)
95
+ remeasure((n) => n + 1);
96
+ });
97
+ return () => cancelAnimationFrame(later);
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [measured]);
59
100
  /*
60
101
  * A LINE POINTS AT WHERE A THING IS, NOT AT WHERE IT WAS.
61
102
  *
@@ -111,14 +152,45 @@ export function Lines({ frame, width, height, scheme, overview, selection, empha
111
152
  watch?.disconnect();
112
153
  };
113
154
  }, [stageRef, views]);
155
+ /*
156
+ * WHICH RECORD A PERSON IS AT: the pointer on it, or the keyboard. A tie
157
+ * the record's card has already said is drawn for that one (`said`);
158
+ * nobody is listened to while no tie waits on it.
159
+ */
160
+ const [pointed, point] = useState(null);
161
+ const waiting = (measured ?? []).some((strand) => strand.tie !== undefined);
162
+ useEffect(() => {
163
+ const stage = stageRef.current;
164
+ if (!waiting || stage === null)
165
+ return;
166
+ const at = (event) => {
167
+ const mark = event.target?.closest?.("[data-graview-pick], [data-graview-view]");
168
+ point(mark?.getAttribute("data-graview-pick") ?? mark?.getAttribute("data-graview-view") ?? null);
169
+ };
170
+ const away = () => point(null);
171
+ stage.addEventListener("pointerover", at);
172
+ stage.addEventListener("focusin", at);
173
+ stage.addEventListener("pointerleave", away);
174
+ stage.addEventListener("focusout", away);
175
+ return () => {
176
+ stage.removeEventListener("pointerover", at);
177
+ stage.removeEventListener("focusin", at);
178
+ stage.removeEventListener("pointerleave", away);
179
+ stage.removeEventListener("focusout", away);
180
+ };
181
+ }, [waiting, stageRef]);
114
182
  if (moving)
115
183
  return null;
116
184
  /*
117
185
  * Without a DOM — the first render, a server — the lines stand on the
118
- * layout's own boxes; and so they do while a card is held, following the
119
- * hand from the layout rather than measuring the page on every move.
186
+ * layout's own boxes. While a card is held they stand on what was
187
+ * measured, moved by as far as the hand has moved each card since: the
188
+ * page is not measured on every move, and a line still starts on the
189
+ * card's drawn edge rather than on the edge of the slot the layout gave
190
+ * it (a gap as tall as the slot is taller), and a tie the card has said
191
+ * stays waiting.
120
192
  */
121
- const strands = (holding ? null : measured) ?? connectorStrands(frame.nodes, frame.connectors, null, overview, scheme);
193
+ const strands = (measured && holding ? movedBy(measured, measuredAt.current, frame.nodes) : measured) ?? connectorStrands(frame.nodes, frame.connectors, null, overview, scheme);
122
194
  const drawnSingles = new Set();
123
195
  for (const strand of strands) {
124
196
  if (strand.edges.length !== 1)
@@ -126,9 +198,20 @@ export function Lines({ frame, width, height, scheme, overview, selection, empha
126
198
  const edge = strand.edges[0];
127
199
  drawnSingles.add(`${strand.connector.kind}|${edge.from}|${edge.to}`);
128
200
  }
129
- return (_jsxs(_Fragment, { children: [_jsx(Connectors, { strands: strands, result: frame, scheme: scheme, overview: overview, selection: selection, emphasis: emphasis, stageRef: stageRef, onPickEdge: onPickEdge, liveOf: liveOf }), _jsx(SelectionTies, { stageRef: stageRef, nodes: frame.nodes, scheme: scheme, selection: selection, store: store, graphNodes: graphNodes, overview: overview, width: width, height: height, onPickEdge: onPickEdge, alreadyDrawn: drawnSingles, tick: asked })] }));
201
+ return (_jsxs(_Fragment, { children: [_jsx(Connectors, { strands: strands, result: frame, scheme: scheme, overview: overview, selection: selection, emphasis: emphasis, stageRef: stageRef, onPickEdge: onPickEdge, liveOf: liveOf, pointed: pointed, ...(provisional ? { provisional } : {}) }), overview && mapChoice(selection, { overview, focusId, beside }, (id) => store.graph.getNode(id)?.kind).records.length > 0 ? (_jsx(Suspense, { fallback: null, children: _jsx(RecordLinks, { frame: frame, width: width, height: height, stageRef: stageRef, tick: asked, store: store, graphVersion: graphNodes, selection: selection, view: { overview, focusId, beside }, rulers: provisional ? { ...RULERS, unsureOf: (edge, from, to) => provisional.get(edgeSelectionId(edge, from, to)) } : RULERS }) })) : null, overview ? null : _jsx(SelectionTies, { ...(provisional ? { provisional } : {}), stageRef: stageRef, nodes: frame.nodes, scheme: scheme, selection: selection, store: store, graphNodes: graphNodes, overview: overview, width: width, height: height, onPickEdge: onPickEdge, alreadyDrawn: drawnSingles, tick: asked })] }));
130
202
  }
131
- function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes, overview, width, height, onPickEdge, alreadyDrawn, tick, }) {
203
+ /*
204
+ * A CHOSEN RECORD'S LINKS FROM ALTITUDE (`record-links-layer.tsx`), fetched
205
+ * the first time a record is chosen up there: a page nobody chooses
206
+ * anything on carries none of it.
207
+ */
208
+ /* The scene's rulers, handed to the links rather than imported by them: a part fetched apart reaching into the scene's own modules splits them around it. */
209
+ const RULERS = { visibleRect, clipQuadratic, unsureDash: PROVISIONAL_DASH, unsureOpacity: PROVISIONAL_OPACITY };
210
+ const recordLinks = /* @__PURE__ */ lazyModule(retryingImport(() => import("./record-links-layer.js")));
211
+ const RecordLinks = /* @__PURE__ */ recordLinks.part((module, props) => _jsx(module.RecordLinks, { ...props }), { what: "The links", quiet: true });
212
+ /** The strip at the top of a card where its title is said, which a tie's words keep off. */
213
+ const TITLE_STRIP = 44;
214
+ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes, overview, width, height, onPickEdge, alreadyDrawn, tick, provisional, }) {
132
215
  const kit = useKit();
133
216
  const ties = useMemo(() => {
134
217
  if (selection.length === 0 || selection.length > 4)
@@ -224,7 +307,7 @@ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes,
224
307
  if (!box || !route)
225
308
  continue;
226
309
  const edge = [...store.graph.allEdges()].find((one) => (one.from === id && selection.includes(one.to)) || (one.to === id && selection.includes(one.from)));
227
- joined.push({ key: `join:${id}`, kind: edge?.kind ?? "", endX: route.to.x, endY: route.to.y, ...route, fromBox: cellBox, toBox: box, proxy: false, edgeId: edge ? edgeSelectionId(edge.kind, edge.from, edge.to) : null });
310
+ joined.push({ key: `join:${id}`, self: id, other: "", others: 1, kind: edge?.kind ?? "", endX: route.to.x, endY: route.to.y, ...route, fromBox: cellBox, toBox: box, proxy: false, edgeId: edge ? edgeSelectionId(edge.kind, edge.from, edge.to) : null });
228
311
  }
229
312
  return joined;
230
313
  }
@@ -319,7 +402,23 @@ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes,
319
402
  }
320
403
  return false;
321
404
  };
322
- const seen = new Set();
405
+ /* The band's chips and the words over them, which a road from a chip goes round. */
406
+ const band = [];
407
+ if (!overview && stageRef.current) {
408
+ for (const node of nodes) {
409
+ if (Math.round(node.plane) !== 1)
410
+ continue;
411
+ const box = measureVisible(stageRef.current, node.id, false);
412
+ if (box)
413
+ band.push(box);
414
+ }
415
+ for (const caption of stageRef.current.querySelectorAll("[data-graview-relation] > span")) {
416
+ const r = caption.getBoundingClientRect();
417
+ if (r.width > 0 && r.height > 0)
418
+ band.push({ x: r.left - stage.left, y: r.top - stage.top, width: r.width, height: r.height });
419
+ }
420
+ }
421
+ const seen = new Map();
323
422
  const found = [];
324
423
  for (const tie of ties) {
325
424
  if (someViewDrawsBoth(tie.self, tie.other))
@@ -328,7 +427,13 @@ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes,
328
427
  const selfHostEl = selfHost
329
428
  ? stageRef.current?.querySelector(`[data-graview-view="${CSS.escape(selfHost.id)}"]`)
330
429
  : null;
331
- const fromCandidates = elementBoxes(tie.self, () => false);
430
+ /*
431
+ * A CHIP OF THE BAND IS THE THING: chosen there, its tie leaves the chip,
432
+ * not a mention of it in the record's card above, whose line then ran
433
+ * down through the card's own chips and across the band.
434
+ */
435
+ const ownChip = !overview && selfHost?.id === tie.self && Math.round(selfHost.plane) === 1 ? measureVisible(stageRef.current, selfHost.id, false) : null;
436
+ const fromCandidates = ownChip ? [ownChip] : elementBoxes(tie.self, () => false);
332
437
  const hasFromEl = fromCandidates.length > 0;
333
438
  if (!hasFromEl && selfHost) {
334
439
  const box = measureVisible(stageRef.current, selfHost.id, false) ?? drawnBox(selfHost, scheme);
@@ -430,19 +535,28 @@ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes,
430
535
  */
431
536
  const key = `${tie.kind}:${Math.round(fromBox.x)},${Math.round(fromBox.y)}` +
432
537
  `:${Math.round(toBox.x)},${Math.round(toBox.y)}`;
433
- if (seen.has(key))
538
+ const twin = seen.get(key);
539
+ if (twin) {
540
+ // Several records of one district on one line: the line counts them.
541
+ twin.others += 1;
434
542
  continue;
435
- seen.add(key);
543
+ }
436
544
  const route = tieRoute(fromBox, toBox);
437
545
  if (!route)
438
546
  continue;
439
- const { from, to, control } = route;
547
+ const { from, control } = route;
548
+ // From a chip of the band, the road runs whole through its gutters, under nothing and over nothing.
549
+ const points = ownChip && fromBox === ownChip ? channelRoute(fromBox, toBox, band, found.length) : undefined;
550
+ const to = points ? points[points.length - 1] : route.to;
440
551
  // The real edge this tie stands for, oriented the way the graph holds it.
441
552
  const oriented = [...store.graph.allEdges()].find((edge) => edge.kind === tie.kind &&
442
553
  ((edge.from === tie.self && edge.to === tie.other) ||
443
554
  (edge.from === tie.other && edge.to === tie.self)));
444
- found.push({
555
+ const line = {
445
556
  key: `${tie.kind}:${tie.self}:${tie.other}`,
557
+ self: tie.self,
558
+ other: tie.other,
559
+ others: 1,
446
560
  kind: tie.kind,
447
561
  endX: to.x,
448
562
  endY: to.y,
@@ -451,85 +565,189 @@ function SelectionTies({ stageRef, nodes, scheme, selection, store, graphNodes,
451
565
  control,
452
566
  fromBox,
453
567
  toBox,
568
+ ...(points ? { points } : {}),
454
569
  proxy: toProxy,
455
570
  edgeId: oriented ? edgeSelectionId(oriented.kind, oriented.from, oriented.to) : null,
456
- });
571
+ };
572
+ seen.set(key, line);
573
+ found.push(line);
574
+ }
575
+ // EVERY LINE SAYS ITS RELATION at its far end, in the chosen record's kind's words, as the same record's links do from altitude.
576
+ for (const line of found) {
577
+ const edge = [...store.graph.allEdges()].find((one) => one.kind === line.kind && ((one.from === line.self && one.to === line.other) || (one.from === line.other && one.to === line.self)));
578
+ const selfNode = store.graph.getNode(line.self);
579
+ const otherNode = store.graph.getNode(line.other);
580
+ if (!edge || !selfNode || !otherNode)
581
+ continue;
582
+ const direction = edge.from === line.self ? "out" : "in";
583
+ const words = edgeWords(store.schema, direction === "out" ? selfNode.kind : otherNode.kind, line.kind, direction);
584
+ line.said = line.proxy ? `${words}: ${counted(store.schema, otherNode.kind, line.others)}` : `${words}: ${labelOf(store.schema.tryDefinition(otherNode.kind), otherNode)}`;
457
585
  }
458
586
  return found;
459
587
  };
460
588
  const [lines, setLines] = useState([]);
589
+ const [labels, setLabels] = useState([]);
461
590
  useLayoutEffect(() => {
462
- setLines(measure());
591
+ const measured = measure();
592
+ setLines(measured);
593
+ setLabels(measured.some((line) => line.said) ? labelsFor(measured) : []);
463
594
  // The measurement depends on the DOM the frame committed, which these
464
595
  // name; `measure` itself closes over nothing that changes without them.
465
596
  // eslint-disable-next-line react-hooks/exhaustive-deps
466
597
  }, [ties, nodes, overview, scheme, width, height, alreadyDrawn, tick, stageRef]);
598
+ /* Each tie's words beside its far end, once, clear of the cards' titles (`tie-labels.ts`). */
599
+ const labelsFor = (measured) => {
600
+ const stageEl = stageRef.current;
601
+ if (!stageEl)
602
+ return [];
603
+ const cards = [];
604
+ for (const host of stageEl.querySelectorAll("[data-graview-view]")) {
605
+ const frame = [...host.children].find((child) => !child.classList.contains("graview-kind-tag"));
606
+ const box = frame ? visibleRect(frame, stageEl) : null;
607
+ if (box)
608
+ cards.push({ box, title: { ...box, height: Math.min(box.height, TITLE_STRIP) } });
609
+ }
610
+ return placeTieLabels(measured.flatMap((line) => (line.said ? [{ key: line.key, said: line.said, end: { x: line.endX, y: line.endY }, far: line.toBox }] : [])), cards, { width, height });
611
+ };
612
+ /*
613
+ * A TIE'S WORDS WAIT TO BE ASKED FOR: shown while the tie, or a card at
614
+ * either end of it, is under the pointer or holds the keyboard.
615
+ */
616
+ const [reached, setReached] = useState(() => new Set());
617
+ const reachable = useRef([]);
618
+ reachable.current = lines.map((line) => ({ key: line.key, fromBox: line.fromBox, toBox: line.toBox, ids: [line.self, line.other], edgeId: line.edgeId }));
619
+ const anySaid = labels.length > 0;
620
+ useEffect(() => {
621
+ const stageEl = stageRef.current;
622
+ if (!stageEl || !anySaid)
623
+ return;
624
+ const settle = (next) => setReached((was) => (was.size === next.size && [...next].every((key) => was.has(key)) ? was : next));
625
+ let queued = 0;
626
+ let last = null;
627
+ const pointed = () => {
628
+ queued = 0;
629
+ if (!last)
630
+ return;
631
+ const stage = stageEl.getBoundingClientRect();
632
+ const target = last.target instanceof Element ? last.target : null;
633
+ settle(tiesReached(reachable.current, {
634
+ point: { x: last.clientX - stage.left, y: last.clientY - stage.top },
635
+ edgeId: target?.closest("[data-graview-edge]")?.getAttribute("data-graview-edge") ?? null,
636
+ }));
637
+ };
638
+ const onMove = (event) => {
639
+ last = event;
640
+ if (!queued)
641
+ queued = requestAnimationFrame(pointed);
642
+ };
643
+ const onLeave = () => {
644
+ last = null;
645
+ settle(new Set());
646
+ };
647
+ const onFocus = (event) => {
648
+ const target = event.target instanceof Element ? event.target : null;
649
+ if (!target)
650
+ return;
651
+ const stage = stageEl.getBoundingClientRect();
652
+ const rect = target.getBoundingClientRect();
653
+ const id = target.closest("[data-graview-pick]")?.getAttribute("data-graview-pick") ?? target.closest("[data-graview-view]")?.getAttribute("data-graview-view");
654
+ settle(tiesReached(reachable.current, {
655
+ ...(id ? { ids: [id] } : {}),
656
+ box: { x: rect.left - stage.left, y: rect.top - stage.top, width: rect.width, height: rect.height },
657
+ }));
658
+ };
659
+ const onBlur = (event) => {
660
+ if (!(event.relatedTarget instanceof Node && stageEl.contains(event.relatedTarget)))
661
+ settle(new Set());
662
+ };
663
+ stageEl.addEventListener("pointermove", onMove, { passive: true });
664
+ stageEl.addEventListener("pointerleave", onLeave);
665
+ stageEl.addEventListener("focusin", onFocus);
666
+ stageEl.addEventListener("focusout", onBlur);
667
+ return () => {
668
+ if (queued)
669
+ cancelAnimationFrame(queued);
670
+ stageEl.removeEventListener("pointermove", onMove);
671
+ stageEl.removeEventListener("pointerleave", onLeave);
672
+ stageEl.removeEventListener("focusin", onFocus);
673
+ stageEl.removeEventListener("focusout", onBlur);
674
+ };
675
+ }, [stageRef, anySaid]);
467
676
  if (lines.length === 0)
468
677
  return null;
469
- return (_jsx("svg", { "aria-hidden": lines.some((line) => line.edgeId) ? undefined : true, "data-graview-ties": lines.length, "data-graview-world": "", width: width, height: height, style: {
470
- position: "absolute",
471
- left: 0,
472
- top: 0,
473
- pointerEvents: "none",
474
- // Over the views: these lines START on an element inside one, and a
475
- // line into the shelf that dives behind the focus card en route says
476
- // nothing.
477
- zIndex: SCENE_LAYERS.lines,
478
- }, children: lines.map((line) => {
479
- const { connector, style } = kitConnector(kit, line.kind);
480
- // A kind the kit keeps quiet is not drawn — still selectable from the inspector.
481
- if (!connector.visible)
482
- return null;
483
- const quadratic = { p0: line.from, c: line.control, p1: line.to };
484
- // The route is the kit's call: the arc the layout chose, its chord, or two elbows.
485
- const d = connector.route === "orthogonal"
486
- ? polylineD([orthogonalPoints(line.from, line.to)])
487
- : (({ p0, c, p1 }) => `M ${p0.x} ${p0.y} Q ${c.x} ${c.y} ${p1.x} ${p1.y}`)(routedQuadratic(connector.route, quadratic));
488
- /*
489
- * A tie that stands for one edge takes the pointer, like any line:
490
- * the hit run is the visible stretch between its two endpoint
491
- * boxes, so pressing a line never steals a card's click.
492
- */
493
- let hit = null;
494
- // A stand-in line takes no pointer: a 14px corridor across half
495
- // the scene stole clicks from every card it crossed, to select a
496
- // relation whose far end is not even drawn. The edge stays
497
- // selectable where it is really drawn, and from the inspector.
498
- if (line.edgeId && onPickEdge && !line.proxy) {
499
- const inside = (box, p) => p.x >= box.x && p.x <= box.x + box.width && p.y >= box.y && p.y <= box.y + box.height;
500
- const points = [];
501
- for (let i = 0; i <= 40; i++) {
502
- const point = routePoint(connector.route, quadratic, i / 40);
503
- if (inside(line.fromBox, point) || inside(line.toBox, point))
504
- continue;
505
- points.push(point);
506
- }
507
- if (points.length >= 2) {
508
- hit = (_jsx("path", { "data-graview-edge": line.edgeId, className: "graview-edge-hit", d: `M ${points[0].x} ${points[0].y} ${points
509
- .slice(1)
510
- .map((point) => `L ${point.x} ${point.y}`)
511
- .join(" ")}`, fill: "none",
512
- // The ends stay clear: a line lands ON a chip, and a hit stroke that reached
513
- // the chip took the click meant for it. Seven percent off each end.
514
- pathLength: 1, strokeDasharray: "0.86", strokeDashoffset: -0.07, stroke: "transparent", strokeWidth: 14, style: { pointerEvents: "stroke", cursor: "pointer" }, onClick: (event) => {
515
- event.stopPropagation();
516
- onPickEdge(line.edgeId, { x: event.clientX, y: event.clientY });
517
- }, onContextMenu: (event) => {
518
- event.preventDefault();
519
- event.stopPropagation();
520
- onPickEdge(line.edgeId, { x: event.clientX, y: event.clientY });
521
- } }));
522
- }
523
- }
524
- return (
525
- /*
526
- * A line to a STAND-IN recedes: the far end is the kind's
527
- * district, not the thing itself, and five of those at full
528
- * strength crossing the scene is a hairball. Present enough to
529
- * say "the rest live over there", never louder than a real tie.
530
- */
531
- _jsxs("g", { opacity: line.proxy ? 0.32 : 0.72, children: [_jsx("path", { "data-graview-tie": line.kind, "data-graview-tie-proxy": line.proxy || undefined, d: d, fill: "none", stroke: connectorStroke(style), strokeWidth: line.proxy ? 1.1 : 1.6, strokeDasharray: CONNECTOR_DASH[style.pattern], strokeLinecap: "round" }), _jsx("circle", { cx: line.endX, cy: line.endY, r: line.proxy ? 2 : 2.6, fill: connectorStroke(style) }), hit] }, line.key));
532
- }) }));
678
+ return (_jsxs(_Fragment, { children: [_jsx("svg", { "aria-hidden": lines.some((line) => line.edgeId) ? undefined : true, "data-graview-ties": lines.length, "data-graview-world": "", width: width, height: height, style: {
679
+ position: "absolute",
680
+ left: 0,
681
+ top: 0,
682
+ pointerEvents: "none",
683
+ // Over the views: these lines START on an element inside one, and a
684
+ // line into the shelf that dives behind the focus card en route says
685
+ // nothing.
686
+ zIndex: SCENE_LAYERS.lines,
687
+ }, children: lines.map((line) => {
688
+ const { connector, style } = kitConnector(kit, line.kind);
689
+ // A kind the kit keeps quiet is not drawn — still selectable from the inspector.
690
+ if (!connector.visible)
691
+ return null;
692
+ // A link made less than sure, not yet kept by a person (FR-172): short dashes, quieter, its basis on hover.
693
+ const unsure = line.edgeId ? provisional?.get(line.edgeId) : undefined;
694
+ const quadratic = { p0: line.from, c: line.control, p1: line.to };
695
+ // The route is the kit's call: the arc the layout chose, its chord, or two elbows.
696
+ const d = line.points
697
+ ? roundedPolylineD(line.points, 10)
698
+ : connector.route === "orthogonal"
699
+ ? polylineD([orthogonalPoints(line.from, line.to)])
700
+ : (({ p0, c, p1 }) => `M ${p0.x} ${p0.y} Q ${c.x} ${c.y} ${p1.x} ${p1.y}`)(routedQuadratic(connector.route, quadratic));
701
+ /*
702
+ * A tie that stands for one edge takes the pointer, like any line:
703
+ * the hit run is the visible stretch between its two endpoint
704
+ * boxes, so pressing a line never steals a card's click.
705
+ */
706
+ let hit = null;
707
+ // A stand-in line takes no pointer: a 14px corridor across half
708
+ // the scene stole clicks from every card it crossed, to select a
709
+ // relation whose far end is not even drawn. The edge stays
710
+ // selectable where it is really drawn, and from the inspector.
711
+ if (line.edgeId && onPickEdge && !line.proxy) {
712
+ const inside = (box, p) => p.x >= box.x && p.x <= box.x + box.width && p.y >= box.y && p.y <= box.y + box.height;
713
+ const points = line.points ? [...line.points] : [];
714
+ for (let i = 0; i <= (line.points ? -1 : 40); i++) {
715
+ const point = routePoint(connector.route, quadratic, i / 40);
716
+ if (inside(line.fromBox, point) || inside(line.toBox, point))
717
+ continue;
718
+ points.push(point);
719
+ }
720
+ if (points.length >= 2) {
721
+ hit = (_jsx("path", { "data-graview-edge": line.edgeId, className: "graview-edge-hit", d: `M ${points[0].x} ${points[0].y} ${points
722
+ .slice(1)
723
+ .map((point) => `L ${point.x} ${point.y}`)
724
+ .join(" ")}`, fill: "none",
725
+ // The ends stay clear: a line lands ON a chip, and a hit stroke that reached
726
+ // the chip took the click meant for it. Seven percent off each end.
727
+ pathLength: 1, strokeDasharray: "0.86", strokeDashoffset: -0.07, stroke: "transparent", strokeWidth: 14, style: { pointerEvents: "stroke", cursor: "pointer" }, onClick: (event) => {
728
+ event.stopPropagation();
729
+ onPickEdge(line.edgeId, { x: event.clientX, y: event.clientY });
730
+ }, onContextMenu: (event) => {
731
+ event.preventDefault();
732
+ event.stopPropagation();
733
+ onPickEdge(line.edgeId, { x: event.clientX, y: event.clientY });
734
+ }, children: unsure !== undefined ? _jsx("title", { children: unsure }) : null }));
735
+ }
736
+ }
737
+ return (
738
+ /*
739
+ * A line to a STAND-IN recedes: the far end is the kind's
740
+ * district, not the thing itself, and five of those at full
741
+ * strength crossing the scene is a hairball. Present enough to
742
+ * say "the rest live over there", never louder than a real tie.
743
+ */
744
+ _jsxs("g", { opacity: (line.proxy ? 0.32 : 0.72) * (unsure !== undefined ? PROVISIONAL_OPACITY : 1), children: [_jsx("path", { "data-graview-tie": line.kind, "data-graview-tie-proxy": line.proxy || undefined, "data-graview-provisional": unsure !== undefined || undefined, d: d, fill: "none", stroke: connectorStroke(style), strokeWidth: line.proxy ? 1.1 : 1.6, strokeDasharray: unsure !== undefined ? PROVISIONAL_DASH : CONNECTOR_DASH[style.pattern], strokeLinecap: "round" }), _jsx("circle", { cx: line.endX, cy: line.endY, r: line.proxy ? 2 : 2.6, fill: connectorStroke(style) }), hit] }, line.key));
745
+ }) }), _jsx("div", { "aria-hidden": "true", "data-graview-world": "", style: { position: "absolute", inset: 0, pointerEvents: "none", zIndex: SCENE_LAYERS.tieLabels }, children: labels.map((label) => {
746
+ const first = lines.find((line) => line.key === label.keys[0]);
747
+ return (_jsx("span", { className: "graview-record-link-said", "data-graview-link-said": first?.kind, "data-graview-lands": first?.proxy ? "district" : "record", "data-graview-said-for": label.keys.length, "data-graview-tie-said": label.keys.some((key) => reached.has(key)) ? "shown" : "waiting",
748
+ /* Kept on the screen: landing near a phone's edge, the words step back by as much as they would run over (the tenth walk). */
749
+ style: { position: "absolute", left: label.x, top: label.y, transform: `translateX(min(0px, calc(${Math.round(width - label.x - 8)}px - 100%)))` }, children: label.said }, label.said));
750
+ }) })] }));
533
751
  }
534
752
  /**
535
753
  * What plane 1 is, said in words, over the run of cards it applies to.
@@ -564,7 +782,8 @@ export function RelationCaptions({ nodes, scheme, width: stageWidth, stageRef, m
564
782
  * from the host's top floated in open ground half a band above the
565
783
  * cards it captions.
566
784
  */
567
- const measured = measureVisible(stageRef.current, node.id, false);
785
+ // By the slot, as the layout made the rows: a chip sits lower in its slot than a card of several in the same row.
786
+ const measured = measureVisible(stageRef.current, node.id, false, true);
568
787
  const { scale } = styleFor(1, scheme);
569
788
  entries.push({
570
789
  key: `${node.via.edgeKind}|${node.via.direction}`,