@graview/react 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +96 -0
- package/README.md +20 -0
- package/dist/activity.d.ts +113 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +207 -0
- package/dist/activity.js.map +1 -0
- package/dist/animation.d.ts +64 -0
- package/dist/animation.d.ts.map +1 -0
- package/dist/animation.js +245 -0
- package/dist/animation.js.map +1 -0
- package/dist/captions.d.ts +43 -0
- package/dist/captions.d.ts.map +1 -0
- package/dist/captions.js +61 -0
- package/dist/captions.js.map +1 -0
- package/dist/channels.d.ts +15 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +114 -0
- package/dist/channels.js.map +1 -0
- package/dist/connectors.d.ts +176 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +837 -0
- package/dist/connectors.js.map +1 -0
- package/dist/context.d.ts +348 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +568 -0
- package/dist/context.js.map +1 -0
- package/dist/drawn.d.ts +60 -0
- package/dist/drawn.d.ts.map +1 -0
- package/dist/drawn.js +58 -0
- package/dist/drawn.js.map +1 -0
- package/dist/edge-signs.d.ts +13 -0
- package/dist/edge-signs.d.ts.map +1 -0
- package/dist/edge-signs.js +76 -0
- package/dist/edge-signs.js.map +1 -0
- package/dist/hooks.d.ts +160 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +457 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/keyboard.d.ts +5 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +178 -0
- package/dist/keyboard.js.map +1 -0
- package/dist/kit.d.ts +10 -0
- package/dist/kit.d.ts.map +1 -0
- package/dist/kit.js +32 -0
- package/dist/kit.js.map +1 -0
- package/dist/local-intelligence.d.ts +31 -0
- package/dist/local-intelligence.d.ts.map +1 -0
- package/dist/local-intelligence.js +66 -0
- package/dist/local-intelligence.js.map +1 -0
- package/dist/motion.d.ts +17 -0
- package/dist/motion.d.ts.map +1 -0
- package/dist/motion.js +26 -0
- package/dist/motion.js.map +1 -0
- package/dist/occupants.d.ts +45 -0
- package/dist/occupants.d.ts.map +1 -0
- package/dist/occupants.js +57 -0
- package/dist/occupants.js.map +1 -0
- package/dist/parallel.d.ts +19 -0
- package/dist/parallel.d.ts.map +1 -0
- package/dist/parallel.js +30 -0
- package/dist/parallel.js.map +1 -0
- package/dist/picking.d.ts +30 -0
- package/dist/picking.d.ts.map +1 -0
- package/dist/picking.js +140 -0
- package/dist/picking.js.map +1 -0
- package/dist/plots.d.ts +106 -0
- package/dist/plots.d.ts.map +1 -0
- package/dist/plots.js +306 -0
- package/dist/plots.js.map +1 -0
- package/dist/pointer.d.ts +27 -0
- package/dist/pointer.d.ts.map +1 -0
- package/dist/pointer.js +39 -0
- package/dist/pointer.js.map +1 -0
- package/dist/presence.d.ts +110 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +280 -0
- package/dist/presence.js.map +1 -0
- package/dist/rails.d.ts +11 -0
- package/dist/rails.d.ts.map +1 -0
- package/dist/rails.js +15 -0
- package/dist/rails.js.map +1 -0
- package/dist/resolved-view.d.ts +78 -0
- package/dist/resolved-view.d.ts.map +1 -0
- package/dist/resolved-view.js +365 -0
- package/dist/resolved-view.js.map +1 -0
- package/dist/robot.d.ts +114 -0
- package/dist/robot.d.ts.map +1 -0
- package/dist/robot.js +93 -0
- package/dist/robot.js.map +1 -0
- package/dist/routes.d.ts +50 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +150 -0
- package/dist/routes.js.map +1 -0
- package/dist/scene-camera.d.ts +25 -0
- package/dist/scene-camera.d.ts.map +1 -0
- package/dist/scene-camera.js +130 -0
- package/dist/scene-camera.js.map +1 -0
- package/dist/scene-hand.d.ts +110 -0
- package/dist/scene-hand.d.ts.map +1 -0
- package/dist/scene-hand.js +317 -0
- package/dist/scene-hand.js.map +1 -0
- package/dist/scene-helpers.d.ts +46 -0
- package/dist/scene-helpers.d.ts.map +1 -0
- package/dist/scene-helpers.js +108 -0
- package/dist/scene-helpers.js.map +1 -0
- package/dist/scene-lines.d.ts +76 -0
- package/dist/scene-lines.d.ts.map +1 -0
- package/dist/scene-lines.js +578 -0
- package/dist/scene-lines.js.map +1 -0
- package/dist/scene-root.d.ts +71 -0
- package/dist/scene-root.d.ts.map +1 -0
- package/dist/scene-root.js +790 -0
- package/dist/scene-root.js.map +1 -0
- package/dist/scene.d.ts +17 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +17 -0
- package/dist/scene.js.map +1 -0
- package/dist/seat-marks.d.ts +35 -0
- package/dist/seat-marks.d.ts.map +1 -0
- package/dist/seat-marks.js +43 -0
- package/dist/seat-marks.js.map +1 -0
- package/dist/settings.d.ts +30 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +73 -0
- package/dist/settings.js.map +1 -0
- package/dist/view-boundary.d.ts +33 -0
- package/dist/view-boundary.d.ts.map +1 -0
- package/dist/view-boundary.js +53 -0
- package/dist/view-boundary.js.map +1 -0
- package/dist/view-host.d.ts +57 -0
- package/dist/view-host.d.ts.map +1 -0
- package/dist/view-host.js +474 -0
- package/dist/view-host.js.map +1 -0
- package/dist/view-registry.d.ts +122 -0
- package/dist/view-registry.d.ts.map +1 -0
- package/dist/view-registry.js +20 -0
- package/dist/view-registry.js.map +1 -0
- package/dist/where-drawn.d.ts +151 -0
- package/dist/where-drawn.d.ts.map +1 -0
- package/dist/where-drawn.js +231 -0
- package/dist/where-drawn.js.map +1 -0
- package/package.json +60 -0
|
@@ -0,0 +1,837 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { edgeSelectionId, edgeOfSelection } from "@graview/layout";
|
|
3
|
+
import { CONNECTOR_DASH, connectorStroke, connectorWidth } from "@graview/render";
|
|
4
|
+
import { kitConnector, useKit } from "./kit.js";
|
|
5
|
+
import { clipPolyline, latticePoints, orthogonalPoints, polylineD, roundedPolylineD, routedQuadratic, } from "./routes.js";
|
|
6
|
+
import { channelRoute } from "./channels.js";
|
|
7
|
+
import { parallelOffsets } from "./parallel.js";
|
|
8
|
+
import { altitudeOpacity, drawnBox, measureVisible, onScreen, stackOpacity } from "./where-drawn.js";
|
|
9
|
+
/**
|
|
10
|
+
* Where a line toward `towards` should MEET a box: on its border, not at its
|
|
11
|
+
* centre.
|
|
12
|
+
*
|
|
13
|
+
* Centre-anchored lines cross the card's own interior on the way out — the
|
|
14
|
+
* yellow dash sawing through the middle of REASONS was this — and where
|
|
15
|
+
* several relations share an endpoint they converge to a single point at the
|
|
16
|
+
* centre, which reads as a knot rather than as several roads arriving. The
|
|
17
|
+
* border is where a road meets a building.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* HOW A TIE RUNS between two measured drawings — a pure decision, so it is
|
|
21
|
+
* testable without a browser.
|
|
22
|
+
*
|
|
23
|
+
* Two drawings stacked in one column used to get a near-straight vertical
|
|
24
|
+
* aimed centre-to-centre — a line THROUGH every row between them, whose
|
|
25
|
+
* hit corridor then stole those rows' clicks. Boxes that share a column
|
|
26
|
+
* stitch along their common right edge, in the gutter; boxes that share a
|
|
27
|
+
* row stitch over the top. Only ends with clear air between them take the
|
|
28
|
+
* direct arc. Returns null when no honest line exists: one drawing inside
|
|
29
|
+
* another is the view's own composition, and anchors within a few pixels
|
|
30
|
+
* are one drawing restating itself.
|
|
31
|
+
*/
|
|
32
|
+
export function tieRoute(fromBox, toBox) {
|
|
33
|
+
const xOverlap = Math.min(fromBox.x + fromBox.width, toBox.x + toBox.width) - Math.max(fromBox.x, toBox.x);
|
|
34
|
+
const yOverlap = Math.min(fromBox.y + fromBox.height, toBox.y + toBox.height) - Math.max(fromBox.y, toBox.y);
|
|
35
|
+
if (xOverlap > 0 && yOverlap > 0)
|
|
36
|
+
return null;
|
|
37
|
+
const stacked = xOverlap > 0.5 * Math.min(fromBox.width, toBox.width);
|
|
38
|
+
const abreast = !stacked && yOverlap > 0.5 * Math.min(fromBox.height, toBox.height);
|
|
39
|
+
if (stacked) {
|
|
40
|
+
const from = { x: fromBox.x + fromBox.width, y: fromBox.y + fromBox.height / 2 };
|
|
41
|
+
const to = { x: toBox.x + toBox.width, y: toBox.y + toBox.height / 2 };
|
|
42
|
+
return {
|
|
43
|
+
from,
|
|
44
|
+
to,
|
|
45
|
+
control: { x: Math.max(from.x, to.x) + 18, y: (from.y + to.y) / 2 },
|
|
46
|
+
mode: "stacked",
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (abreast) {
|
|
50
|
+
const from = { x: fromBox.x + fromBox.width / 2, y: fromBox.y };
|
|
51
|
+
const to = { x: toBox.x + toBox.width / 2, y: toBox.y };
|
|
52
|
+
return {
|
|
53
|
+
from,
|
|
54
|
+
to,
|
|
55
|
+
control: { x: (from.x + to.x) / 2, y: Math.min(from.y, to.y) - 18 },
|
|
56
|
+
mode: "abreast",
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const toCentre = { x: toBox.x + toBox.width / 2, y: toBox.y + toBox.height / 2 };
|
|
60
|
+
const fromCentre = { x: fromBox.x + fromBox.width / 2, y: fromBox.y + fromBox.height / 2 };
|
|
61
|
+
const from = edgePoint(fromBox, toCentre);
|
|
62
|
+
const to = edgePoint(toBox, fromCentre);
|
|
63
|
+
const direct = Math.hypot(to.x - from.x, to.y - from.y);
|
|
64
|
+
if (direct < 8)
|
|
65
|
+
return null;
|
|
66
|
+
const bow = Math.min(36, direct * 0.12);
|
|
67
|
+
const nx = -(to.y - from.y) / direct;
|
|
68
|
+
const ny = (to.x - from.x) / direct;
|
|
69
|
+
return {
|
|
70
|
+
from,
|
|
71
|
+
to,
|
|
72
|
+
control: { x: (from.x + to.x) / 2 + nx * bow, y: (from.y + to.y) / 2 + ny * bow },
|
|
73
|
+
mode: "direct",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function edgePoint(box, towards) {
|
|
77
|
+
const cx = box.x + box.width / 2;
|
|
78
|
+
const cy = box.y + box.height / 2;
|
|
79
|
+
const dx = towards.x - cx;
|
|
80
|
+
const dy = towards.y - cy;
|
|
81
|
+
if (dx === 0 && dy === 0)
|
|
82
|
+
return { x: cx, y: cy };
|
|
83
|
+
// How far along the direction the border sits, on whichever side is hit
|
|
84
|
+
// first — the standard slab intersection, for an axis-aligned box.
|
|
85
|
+
const tx = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
|
|
86
|
+
const ty = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
|
|
87
|
+
const t = Math.min(tx, ty);
|
|
88
|
+
// A few pixels shy of the border, so the stroke's rounded cap does not
|
|
89
|
+
// poke into the card.
|
|
90
|
+
const out = Math.max(0, t * 0.98);
|
|
91
|
+
return { x: cx + dx * out, y: cy + dy * out };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Whether the connector layer draws this line — ONE rule, shared with the
|
|
95
|
+
* ties layer so a relation is never drawn twice with different manners.
|
|
96
|
+
* Above the stack every relation earns its ink; inside it a line must touch
|
|
97
|
+
* a raised node and must not end on a receded group ("some of these" is not
|
|
98
|
+
* a relationship anyone can read).
|
|
99
|
+
*/
|
|
100
|
+
function connectorShows(connector, byId, overview) {
|
|
101
|
+
const from = byId.get(connector.from);
|
|
102
|
+
const to = byId.get(connector.to);
|
|
103
|
+
if (!from || !to)
|
|
104
|
+
return false;
|
|
105
|
+
if (overview)
|
|
106
|
+
return true;
|
|
107
|
+
const raised = Math.round(from.plane) === 1 || Math.round(to.plane) === 1;
|
|
108
|
+
const vagueEnd = (node) => Boolean(node.aggregate) && Math.round(node.plane) === 2;
|
|
109
|
+
return raised && !vagueEnd(from) && !vagueEnd(to);
|
|
110
|
+
}
|
|
111
|
+
const newPass = () => ({ clips: new Map(), members: new Map(), marks: new Map() });
|
|
112
|
+
function marksIn(host, pass) {
|
|
113
|
+
let marks = pass.marks.get(host);
|
|
114
|
+
if (marks)
|
|
115
|
+
return marks;
|
|
116
|
+
marks = new Map();
|
|
117
|
+
for (const el of host.querySelectorAll("[data-graview-pick], [data-graview-slot]")) {
|
|
118
|
+
for (const id of new Set([el.getAttribute("data-graview-pick"), el.getAttribute("data-graview-slot")])) {
|
|
119
|
+
if (id === null)
|
|
120
|
+
continue;
|
|
121
|
+
const list = marks.get(id);
|
|
122
|
+
if (list)
|
|
123
|
+
list.push(el);
|
|
124
|
+
else
|
|
125
|
+
marks.set(id, [el]);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
pass.marks.set(host, marks);
|
|
129
|
+
return marks;
|
|
130
|
+
}
|
|
131
|
+
function clipOf(parent, pass) {
|
|
132
|
+
let held = pass.clips.get(parent);
|
|
133
|
+
if (held === undefined) {
|
|
134
|
+
const style = getComputedStyle(parent);
|
|
135
|
+
const clips = style.overflow !== "visible" || style.overflowX !== "visible" || style.overflowY !== "visible";
|
|
136
|
+
held = clips ? parent.getBoundingClientRect() : null;
|
|
137
|
+
pass.clips.set(parent, held);
|
|
138
|
+
}
|
|
139
|
+
return held;
|
|
140
|
+
}
|
|
141
|
+
function withinItsScroller(el, rect, pass) {
|
|
142
|
+
let parent = el.parentElement;
|
|
143
|
+
while (parent !== null) {
|
|
144
|
+
const box = clipOf(parent, pass);
|
|
145
|
+
if (box) {
|
|
146
|
+
const overlaps = rect.right > box.left + 1 &&
|
|
147
|
+
rect.left < box.right - 1 &&
|
|
148
|
+
rect.bottom > box.top + 1 &&
|
|
149
|
+
rect.top < box.bottom - 1;
|
|
150
|
+
if (!overlaps)
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (parent.hasAttribute("data-graview-view"))
|
|
154
|
+
break;
|
|
155
|
+
parent = parent.parentElement;
|
|
156
|
+
}
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* The elements a view draws for a MEMBER of a drawn node — a session's span
|
|
161
|
+
* in the week, a drill's chip in a session card — measured in stage space
|
|
162
|
+
* and sorted compact-first. Chrome is not scene: an offstage rail repeating
|
|
163
|
+
* a name as a chip is never an anchor.
|
|
164
|
+
*/
|
|
165
|
+
function memberBoxes(stageEl, host, memberId, pass) {
|
|
166
|
+
if (!stageEl || !host || typeof document === "undefined")
|
|
167
|
+
return [];
|
|
168
|
+
let inHost = pass.members.get(host);
|
|
169
|
+
if (!inHost)
|
|
170
|
+
pass.members.set(host, (inHost = new Map()));
|
|
171
|
+
const known = inHost.get(memberId);
|
|
172
|
+
if (known)
|
|
173
|
+
return known;
|
|
174
|
+
const stage = stageEl.getBoundingClientRect();
|
|
175
|
+
const boxes = [];
|
|
176
|
+
for (const el of marksIn(host, pass).get(memberId) ?? []) {
|
|
177
|
+
if (el.closest("[data-graview-offstage]"))
|
|
178
|
+
continue;
|
|
179
|
+
const rect = el.getBoundingClientRect();
|
|
180
|
+
if (rect.width <= 2 || rect.height <= 2)
|
|
181
|
+
continue;
|
|
182
|
+
/*
|
|
183
|
+
* A MEMBER SCROLLED OUT OF ITS PANEL IS NOT AN ANCHOR.
|
|
184
|
+
*
|
|
185
|
+
* `getBoundingClientRect` answers for an element that has been scrolled
|
|
186
|
+
* past the edge of its own scroller just as happily as for one you can
|
|
187
|
+
* see — with coordinates that are outside the panel, and often outside
|
|
188
|
+
* the stage. A line drawn to that lands somewhere there is nothing,
|
|
189
|
+
* usually across a neighbouring card, and reads as the picture lying
|
|
190
|
+
* about the graph.
|
|
191
|
+
*
|
|
192
|
+
* Where the member is out of sight the line falls back to the host,
|
|
193
|
+
* which is the honest answer: the thing is in there somewhere, and the
|
|
194
|
+
* panel is the finest box that is actually true.
|
|
195
|
+
*/
|
|
196
|
+
if (!withinItsScroller(el, rect, pass))
|
|
197
|
+
continue;
|
|
198
|
+
boxes.push({
|
|
199
|
+
x: rect.left - stage.left,
|
|
200
|
+
y: rect.top - stage.top,
|
|
201
|
+
width: rect.width,
|
|
202
|
+
height: rect.height,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
boxes.sort((a, b) => a.width * a.height - b.width * b.height);
|
|
206
|
+
inHost.set(memberId, boxes);
|
|
207
|
+
return boxes;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Resolves the frame's connectors into the strands the scene will draw.
|
|
211
|
+
*
|
|
212
|
+
* Pure of React and null-safe without a DOM: headless, every strand is
|
|
213
|
+
* anchored on the layout's own boxes, which is the old behaviour exactly.
|
|
214
|
+
* From altitude nothing is unpicked — the constellation is a picture of
|
|
215
|
+
* kinds, and a line from a span inside the shrunk stamp would say nothing
|
|
216
|
+
* the road between two districts does not.
|
|
217
|
+
*/
|
|
218
|
+
export function connectorStrands(nodes, connectors, stageEl, overview, scheme) {
|
|
219
|
+
const byId = new Map(nodes.map((node) => [node.id, node]));
|
|
220
|
+
const pass = newPass();
|
|
221
|
+
const hostEls = new Map();
|
|
222
|
+
const hostOf = (id) => {
|
|
223
|
+
if (!stageEl || typeof document === "undefined")
|
|
224
|
+
return null;
|
|
225
|
+
let held = hostEls.get(id);
|
|
226
|
+
if (held === undefined) {
|
|
227
|
+
held = stageEl.querySelector(`[data-graview-view="${CSS.escape(id)}"]`);
|
|
228
|
+
hostEls.set(id, held);
|
|
229
|
+
}
|
|
230
|
+
return held;
|
|
231
|
+
};
|
|
232
|
+
// Every drawn box, measured once; a line dives under any it merely crosses.
|
|
233
|
+
const boxes = new Map();
|
|
234
|
+
for (const node of nodes) {
|
|
235
|
+
const box = measureVisible(stageEl, node.id, overview) ?? drawnBox(node, scheme);
|
|
236
|
+
if (box)
|
|
237
|
+
boxes.set(node.id, box);
|
|
238
|
+
}
|
|
239
|
+
/*
|
|
240
|
+
* THE DRAWINGS INSIDE THE VIEWS ARE OBSTACLES TOO.
|
|
241
|
+
*
|
|
242
|
+
* A line dives under any card it merely crosses — but "card" meant a
|
|
243
|
+
* DRAWN NODE, and the things a lens draws inside itself are not drawn
|
|
244
|
+
* nodes. So a line across the week passed over ten shift cards, and the
|
|
245
|
+
* connector layer sits above the hosts, so it passed over them literally:
|
|
246
|
+
* a stroke painted across somebody's Tuesday. Going under is what the
|
|
247
|
+
* clipping already does; it just had nothing to clip against in there.
|
|
248
|
+
*
|
|
249
|
+
* Measured once per pass, keyed by the id each drawing wears, so a
|
|
250
|
+
* strand can leave its own two ends out.
|
|
251
|
+
*/
|
|
252
|
+
const memberBoxesById = new Map();
|
|
253
|
+
if (stageEl && typeof document !== "undefined") {
|
|
254
|
+
const stage = stageEl.getBoundingClientRect();
|
|
255
|
+
for (const el of stageEl.querySelectorAll("[data-graview-pick], [data-graview-slot]")) {
|
|
256
|
+
if (el.closest("[data-graview-offstage]"))
|
|
257
|
+
continue;
|
|
258
|
+
const id = el.getAttribute("data-graview-pick") ?? el.getAttribute("data-graview-slot");
|
|
259
|
+
if (id === null)
|
|
260
|
+
continue;
|
|
261
|
+
const rect = el.getBoundingClientRect();
|
|
262
|
+
if (rect.width <= 2 || rect.height <= 2)
|
|
263
|
+
continue;
|
|
264
|
+
/*
|
|
265
|
+
* A ROTATED HEADER'S BOUNDING BOX is a huge diagonal rectangle whose
|
|
266
|
+
* border is nowhere near the visible text — a matrix's column names
|
|
267
|
+
* are written on the slant, and taking their boxes as obstacles
|
|
268
|
+
* erased every line in the picture. Anything approaching the size of
|
|
269
|
+
* the stage is furniture, not a drawing.
|
|
270
|
+
*/
|
|
271
|
+
if (rect.width > stage.width * 0.6 && rect.height > stage.height * 0.6)
|
|
272
|
+
continue;
|
|
273
|
+
const held = memberBoxesById.get(id);
|
|
274
|
+
const box = { x: rect.left - stage.left, y: rect.top - stage.top, width: rect.width, height: rect.height };
|
|
275
|
+
if (held)
|
|
276
|
+
held.push(box);
|
|
277
|
+
else
|
|
278
|
+
memberBoxesById.set(id, [box]);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const obstaclesFor = (a, b) => {
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const [id, box] of boxes)
|
|
284
|
+
if (id !== a && id !== b)
|
|
285
|
+
out.push(box);
|
|
286
|
+
for (const [id, drawn] of memberBoxesById)
|
|
287
|
+
if (id !== a && id !== b)
|
|
288
|
+
out.push(...drawn);
|
|
289
|
+
return out;
|
|
290
|
+
};
|
|
291
|
+
// The relation band's chips, for a line whose both ends are in it.
|
|
292
|
+
const inBand = (id) => Math.round(byId.get(id)?.plane ?? -1) === 1;
|
|
293
|
+
const bandBoxes = [];
|
|
294
|
+
for (const node of nodes) {
|
|
295
|
+
const box = boxes.get(node.id);
|
|
296
|
+
if (box && Math.round(node.plane) === 1)
|
|
297
|
+
bandBoxes.push(box);
|
|
298
|
+
}
|
|
299
|
+
const bandFor = (a, b) => (!overview && inBand(a) && inBand(b) ? bandBoxes : undefined);
|
|
300
|
+
const strands = [];
|
|
301
|
+
for (const connector of connectors) {
|
|
302
|
+
if (!connectorShows(connector, byId, overview))
|
|
303
|
+
continue;
|
|
304
|
+
const fromHost = boxes.get(connector.from);
|
|
305
|
+
const toHost = boxes.get(connector.to);
|
|
306
|
+
if (!fromHost || !toHost)
|
|
307
|
+
continue;
|
|
308
|
+
const obstacles = obstaclesFor(connector.from, connector.to);
|
|
309
|
+
const edges = connector.edges;
|
|
310
|
+
/*
|
|
311
|
+
* At altitude a line runs district to district — unless a district has
|
|
312
|
+
* been opened in place and draws the member the line is about, in which
|
|
313
|
+
* case it lands on the member. Whether one is drawn is the only test.
|
|
314
|
+
*/
|
|
315
|
+
const drawnMember = overview &&
|
|
316
|
+
edges.some((edge) => (edge.from !== connector.from && memberBoxes(stageEl, hostOf(connector.from), edge.from, pass).length > 0) ||
|
|
317
|
+
(edge.to !== connector.to && memberBoxes(stageEl, hostOf(connector.to), edge.to, pass).length > 0));
|
|
318
|
+
const band = bandFor(connector.from, connector.to);
|
|
319
|
+
if ((overview && !drawnMember) || connector.loop || edges.length === 0) {
|
|
320
|
+
strands.push({
|
|
321
|
+
key: connector.id,
|
|
322
|
+
connector,
|
|
323
|
+
edges,
|
|
324
|
+
fromBox: fromHost,
|
|
325
|
+
toBox: toHost,
|
|
326
|
+
fromAnchor: connector.from,
|
|
327
|
+
toAnchor: connector.to,
|
|
328
|
+
obstacles,
|
|
329
|
+
hosts: [],
|
|
330
|
+
...(band ? { band } : {}),
|
|
331
|
+
});
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const groups = new Map();
|
|
335
|
+
for (const edge of edges) {
|
|
336
|
+
const fromCandidates = edge.from === connector.from ? [] : memberBoxes(stageEl, hostOf(connector.from), edge.from, pass);
|
|
337
|
+
const toCandidates = edge.to === connector.to ? [] : memberBoxes(stageEl, hostOf(connector.to), edge.to, pass);
|
|
338
|
+
/*
|
|
339
|
+
* THE FOCUS THAT DRAWS BOTH ENDS HAS DRAWN THE RELATION. The lists
|
|
340
|
+
* view draws every task inside its list; a line from each list column
|
|
341
|
+
* to the same task's chip in the band restated, twelve times over the
|
|
342
|
+
* panel, what the columns already said. Only the FOCUS counts: a card
|
|
343
|
+
* in the band summarising its members as chips has not drawn the
|
|
344
|
+
* relation between the focus's spans and itself — those lines are the
|
|
345
|
+
* point of raising it, and silencing them left a calendar whose
|
|
346
|
+
* entries seemed to belong to no list until one was selected.
|
|
347
|
+
*/
|
|
348
|
+
const focusEnd = [connector.from, connector.to].find((id) => Math.round(byId.get(id)?.plane ?? -1) === 0);
|
|
349
|
+
if (!overview &&
|
|
350
|
+
focusEnd !== undefined &&
|
|
351
|
+
(edge.from !== connector.from || edge.to !== connector.to) &&
|
|
352
|
+
memberBoxes(stageEl, hostOf(focusEnd), focusEnd === connector.from ? edge.to : edge.from, pass).length > 0) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
/*
|
|
356
|
+
* A LINE LANDS ON A DRAWING OF THE THING, OR IS NOT DRAWN. A group in
|
|
357
|
+
* focus draws what its view draws — a calendar, the entries with a
|
|
358
|
+
* time — and an edge to a member it does not draw has no end here.
|
|
359
|
+
* Anchored on the panel instead, three someday tasks with no time
|
|
360
|
+
* became one line from their list into the middle of the week.
|
|
361
|
+
*/
|
|
362
|
+
const focusNode = focusEnd !== undefined ? byId.get(focusEnd) : undefined;
|
|
363
|
+
if (!overview &&
|
|
364
|
+
focusNode?.aggregate &&
|
|
365
|
+
stageEl &&
|
|
366
|
+
(focusEnd === connector.from ? edge.from !== connector.from && fromCandidates.length === 0 : edge.to !== connector.to && toCandidates.length === 0)) {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
const fromAnchor = fromCandidates.length > 0 ? edge.from : connector.from;
|
|
370
|
+
const toAnchor = toCandidates.length > 0 ? edge.to : connector.to;
|
|
371
|
+
const key = `${connector.id}|${fromAnchor}|${toAnchor}`;
|
|
372
|
+
const held = groups.get(key);
|
|
373
|
+
if (held) {
|
|
374
|
+
held.edges.push(edge);
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
/*
|
|
378
|
+
* THE CLOSEST PAIR of drawings, when an end is drawn more than once —
|
|
379
|
+
* the same rule a tie follows, so a line and a tie to the same chip
|
|
380
|
+
* agree about which chip.
|
|
381
|
+
*/
|
|
382
|
+
const froms = fromCandidates.length > 0 ? fromCandidates : [fromHost];
|
|
383
|
+
const tos = toCandidates.length > 0 ? toCandidates : [toHost];
|
|
384
|
+
let fromBox = froms[0];
|
|
385
|
+
let toBox = tos[0];
|
|
386
|
+
let nearest = Infinity;
|
|
387
|
+
for (const a of froms) {
|
|
388
|
+
for (const b of tos) {
|
|
389
|
+
const gap = Math.hypot(a.x + a.width / 2 - (b.x + b.width / 2), a.y + a.height / 2 - (b.y + b.height / 2));
|
|
390
|
+
if (gap < nearest) {
|
|
391
|
+
nearest = gap;
|
|
392
|
+
fromBox = a;
|
|
393
|
+
toBox = b;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
groups.set(key, { edges: [edge], fromBox, toBox, fromAnchor, toAnchor });
|
|
398
|
+
}
|
|
399
|
+
for (const [key, group] of groups) {
|
|
400
|
+
strands.push({
|
|
401
|
+
key,
|
|
402
|
+
connector,
|
|
403
|
+
obstacles,
|
|
404
|
+
hosts: [
|
|
405
|
+
...(group.fromAnchor !== connector.from ? [fromHost] : []),
|
|
406
|
+
...(group.toAnchor !== connector.to ? [toHost] : []),
|
|
407
|
+
],
|
|
408
|
+
...(band ? { band } : {}),
|
|
409
|
+
...group,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return strands;
|
|
414
|
+
}
|
|
415
|
+
function quadraticAt(q, t) {
|
|
416
|
+
const a = (1 - t) * (1 - t);
|
|
417
|
+
const b = 2 * (1 - t) * t;
|
|
418
|
+
const c = t * t;
|
|
419
|
+
return {
|
|
420
|
+
x: a * q.p0.x + b * q.c.x + c * q.p1.x,
|
|
421
|
+
y: a * q.p0.y + b * q.c.y + c * q.p1.y,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/** The piece of a quadratic between parameters `a` and `b`, as a quadratic. */
|
|
425
|
+
function subQuadratic(q, a, b) {
|
|
426
|
+
// The polar form: the control point of the piece is the blossom at (a, b).
|
|
427
|
+
const w0 = (1 - a) * (1 - b);
|
|
428
|
+
const w1 = (1 - a) * b + a * (1 - b);
|
|
429
|
+
const w2 = a * b;
|
|
430
|
+
return {
|
|
431
|
+
p0: quadraticAt(q, a),
|
|
432
|
+
c: { x: w0 * q.p0.x + w1 * q.c.x + w2 * q.p1.x, y: w0 * q.p0.y + w1 * q.c.y + w2 * q.p1.y },
|
|
433
|
+
p1: quadraticAt(q, b),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* THE VISIBLE RUNS of a curve: every stretch of it that lies outside the
|
|
438
|
+
* given boxes, as exact pieces of the same curve.
|
|
439
|
+
*
|
|
440
|
+
* A line used to be clipped by sitting BEHIND the cards, which was free and
|
|
441
|
+
* was also why a line anchored on a span inside the week never showed the
|
|
442
|
+
* stretch from the span to the panel's edge — it was under the panel. The
|
|
443
|
+
* lines now sit over the cards and clip themselves: out of the box they
|
|
444
|
+
* start in, into the box they end in, and under any card they cross on
|
|
445
|
+
* the way. Sampled, then each crossing refined by bisection, so a run ends
|
|
446
|
+
* on a border rather than a sample shy of it.
|
|
447
|
+
*/
|
|
448
|
+
export function clipQuadratic(q, boxes) {
|
|
449
|
+
const inside = (p) => boxes.some((box) => p.x > box.x && p.x < box.x + box.width && p.y > box.y && p.y < box.y + box.height);
|
|
450
|
+
const out = (t) => !inside(quadraticAt(q, t));
|
|
451
|
+
const STEPS = 64;
|
|
452
|
+
// Where a sample and its neighbour disagree, the border lies between them.
|
|
453
|
+
const refine = (lo, hi, loOut) => {
|
|
454
|
+
for (let i = 0; i < 10; i++) {
|
|
455
|
+
const mid = (lo + hi) / 2;
|
|
456
|
+
if (out(mid) === loOut)
|
|
457
|
+
lo = mid;
|
|
458
|
+
else
|
|
459
|
+
hi = mid;
|
|
460
|
+
}
|
|
461
|
+
return (lo + hi) / 2;
|
|
462
|
+
};
|
|
463
|
+
const runs = [];
|
|
464
|
+
let open = null;
|
|
465
|
+
let wasOut = out(0);
|
|
466
|
+
if (wasOut)
|
|
467
|
+
open = 0;
|
|
468
|
+
for (let i = 1; i <= STEPS; i++) {
|
|
469
|
+
const t = i / STEPS;
|
|
470
|
+
const isOut = out(t);
|
|
471
|
+
if (isOut === wasOut)
|
|
472
|
+
continue;
|
|
473
|
+
const at = refine((i - 1) / STEPS, t, wasOut);
|
|
474
|
+
if (isOut)
|
|
475
|
+
open = at;
|
|
476
|
+
else if (open !== null) {
|
|
477
|
+
if (at - open > 1e-3)
|
|
478
|
+
runs.push(subQuadratic(q, open, at));
|
|
479
|
+
open = null;
|
|
480
|
+
}
|
|
481
|
+
wasOut = isOut;
|
|
482
|
+
}
|
|
483
|
+
if (open !== null && 1 - open > 1e-3)
|
|
484
|
+
runs.push(subQuadratic(q, open, 1));
|
|
485
|
+
return runs;
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Connectors, drawn in SVG over the scene on BOTH renderer paths.
|
|
489
|
+
*
|
|
490
|
+
* A deliberate choice, not an omission: the GPU pipeline earns its cost on
|
|
491
|
+
* per-plane blur of captured pixels, and lines are the one thing it would be
|
|
492
|
+
* worse at. SVG strokes stay crisp at any scale, carry their edge kind into
|
|
493
|
+
* the DOM where a test or a screen reader can find it, and cost nothing to
|
|
494
|
+
* restyle. The capture inside a `layoutsubtree` canvas is not disturbed,
|
|
495
|
+
* because this sits outside it.
|
|
496
|
+
*/
|
|
497
|
+
export function Connectors({ strands, result, scheme, overview, selection, emphasis, stageRef, liveOf, onPickEdge, }) {
|
|
498
|
+
const kit = useKit();
|
|
499
|
+
/*
|
|
500
|
+
* ROADS. From altitude, with the districts on the lattice, a line between
|
|
501
|
+
* two of them runs along the lattice's diagonals rather than bowing
|
|
502
|
+
* around the middle — a road on the same grid the buildings stand on.
|
|
503
|
+
* Inside the stack the kit's route stands.
|
|
504
|
+
*/
|
|
505
|
+
const onLattice = new Set(result.nodes.filter((node) => node.plot !== undefined && Math.round(node.plane) === 2).map((node) => node.id));
|
|
506
|
+
const roadBetween = (connector) => overview && result.city !== undefined && onLattice.has(connector.from) && onLattice.has(connector.to);
|
|
507
|
+
/*
|
|
508
|
+
* Selecting DRAWS ITS RELATIONS and recedes the rest.
|
|
509
|
+
*
|
|
510
|
+
* The selection holds real node ids; a connector in the overview holds the
|
|
511
|
+
* ids of whatever is DRAWN, which up there is always a kind card. Comparing
|
|
512
|
+
* them directly matched nothing — so carrying an ordinary selection into the
|
|
513
|
+
* Graview, or clicking a target inside the shrunk picture, receded every
|
|
514
|
+
* line at once and blanked the thing you rose to look at.
|
|
515
|
+
*
|
|
516
|
+
* So the selection is resolved the same way `connectorsFor` resolves an
|
|
517
|
+
* endpoint: a node that is not drawn is represented by the card that stands
|
|
518
|
+
* for it. And a selection that resolves to nothing on screen means NO
|
|
519
|
+
* emphasis rather than none-of-the-above, which is the rule every view here
|
|
520
|
+
* follows.
|
|
521
|
+
*/
|
|
522
|
+
const chosen = onScreen(result.nodes, selection);
|
|
523
|
+
const touches = (connector) => chosen.size === 0 || chosen.has(connector.from) || chosen.has(connector.to);
|
|
524
|
+
/*
|
|
525
|
+
* Inside the stack the SELECTED THING'S OWN LINES come forward.
|
|
526
|
+
*
|
|
527
|
+
* A strand knows the real edges it stands for, so no resolution is needed:
|
|
528
|
+
* a line touches the selection when one of its edges does. This is what
|
|
529
|
+
* the ties layer used to add on top — and then had to yield, one relation
|
|
530
|
+
* at a time, to the line already here. One line, lit.
|
|
531
|
+
*/
|
|
532
|
+
const chosenReal = new Set(selection.filter((id) => edgeOfSelection(id) === null));
|
|
533
|
+
/*
|
|
534
|
+
* The live view standing in the middle of the ring, whose box is the one
|
|
535
|
+
* thing a road between districts must not run beneath.
|
|
536
|
+
*/
|
|
537
|
+
const stampNode = overview
|
|
538
|
+
? result.nodes.find((node) => Math.round(node.plane) === 0)
|
|
539
|
+
: undefined;
|
|
540
|
+
const stamp = stampNode
|
|
541
|
+
? (measureVisible(stageRef.current, stampNode.id, false) ?? drawnBox(stampNode, scheme))
|
|
542
|
+
: null;
|
|
543
|
+
if (strands.length === 0)
|
|
544
|
+
return null;
|
|
545
|
+
/*
|
|
546
|
+
* A CHOSEN MEMBER IS NOT ITS WHOLE DISTRICT.
|
|
547
|
+
*
|
|
548
|
+
* Up at altitude a selection is resolved to the card that stands for it,
|
|
549
|
+
* because that is what a connector actually points at — and that is right
|
|
550
|
+
* for deciding WHICH CARDS a line touches. It is wrong for deciding which
|
|
551
|
+
* LINE: with the volunteers opened, every one of the seven covered-by
|
|
552
|
+
* strands ends at the volunteers card, so choosing Ada lit Bo's shifts,
|
|
553
|
+
* and Cass's, and Dev's. Clicking a name in an opened district changed
|
|
554
|
+
* nothing about the picture, which is the one thing clicking a name is for.
|
|
555
|
+
*
|
|
556
|
+
* A strand knows the real edges it stands for. When the selection names
|
|
557
|
+
* something those edges actually mention, that is the finer and truer
|
|
558
|
+
* answer and it wins; when it names none of them — a district chosen as a
|
|
559
|
+
* district, a stop carried in from elsewhere — the card rule stands.
|
|
560
|
+
*/
|
|
561
|
+
const touchesChosen = (strand) => strand.edges.some((edge) => chosenReal.has(edge.from) || chosenReal.has(edge.to));
|
|
562
|
+
const anyStrandChosen = chosenReal.size > 0 && strands.some(touchesChosen);
|
|
563
|
+
/** How many lines each relation is drawing at once, for the crowd rule. */
|
|
564
|
+
const siblings = new Map();
|
|
565
|
+
for (const strand of strands)
|
|
566
|
+
siblings.set(strand.connector.id, (siblings.get(strand.connector.id) ?? 0) + 1);
|
|
567
|
+
// Lines that share both ends, fanned so each is its own line (see parallel.ts).
|
|
568
|
+
const fanned = parallelOffsets(strands.map((strand) => ({ key: strand.key, a: strand.fromAnchor, b: strand.toAnchor })));
|
|
569
|
+
const drawn = strands.map((strand, lane) => {
|
|
570
|
+
const { connector, fromBox, toBox } = strand;
|
|
571
|
+
const fromCentre = { x: fromBox.x + fromBox.width / 2, y: fromBox.y + fromBox.height / 2 };
|
|
572
|
+
const toCentre = { x: toBox.x + toBox.width / 2, y: toBox.y + toBox.height / 2 };
|
|
573
|
+
/*
|
|
574
|
+
* In the overview a line meets a card at its border, facing the
|
|
575
|
+
* other end — the border is honest up there, because a kind card
|
|
576
|
+
* fills its box. Inside the stack a host is a band slot with the
|
|
577
|
+
* panel centred somewhere in it, so a border anchor dangles in open
|
|
578
|
+
* ground; centre-to-centre is right, and the run inside each box is
|
|
579
|
+
* clipped away below.
|
|
580
|
+
*/
|
|
581
|
+
const from = overview ? edgePoint(fromBox, toCentre) : fromCentre;
|
|
582
|
+
const to = overview ? edgePoint(toBox, fromCentre) : toCentre;
|
|
583
|
+
/*
|
|
584
|
+
* A LOOP, where both ends are the same card.
|
|
585
|
+
*
|
|
586
|
+
* "A task waits for a task" is a real fact about the domain and the
|
|
587
|
+
* constellation is exactly where you would look for it — but as a line
|
|
588
|
+
* it has zero length. Drawn as an arc leaving the card's top and
|
|
589
|
+
* returning to its right, which is how every graph drawing has shown a
|
|
590
|
+
* self-relation for fifty years.
|
|
591
|
+
*/
|
|
592
|
+
const self = connector.loop === true;
|
|
593
|
+
// Stroke treatment is derived from the edge kind, so `protects` can
|
|
594
|
+
// never be mistaken for `assigned-to`.
|
|
595
|
+
const { connector: kitLine, style } = kitConnector(kit, connector.kind);
|
|
596
|
+
// A kind the kit keeps quiet is not drawn; the legend still lists it.
|
|
597
|
+
if (!kitLine.visible)
|
|
598
|
+
return null;
|
|
599
|
+
const radius = self ? Math.max(22, Math.min(fromBox.width, fromBox.height) * 0.3) : 0;
|
|
600
|
+
/*
|
|
601
|
+
* The loop SITS ON the card's top edge, off to the right.
|
|
602
|
+
*
|
|
603
|
+
* Centred it drew a ring straight through the card's own name; pushed
|
|
604
|
+
* clear of the corner it became a circle floating in the ground next
|
|
605
|
+
* to a card, which from the Graview read as a stray mark rather than
|
|
606
|
+
* as a relation belonging to anything. Overlapping the edge by a few
|
|
607
|
+
* pixels is what makes it hang off the card instead of near it.
|
|
608
|
+
*/
|
|
609
|
+
const anchor = self
|
|
610
|
+
? {
|
|
611
|
+
x: fromCentre.x + fromBox.width * 0.22,
|
|
612
|
+
y: fromCentre.y - fromBox.height / 2 - radius + 7,
|
|
613
|
+
}
|
|
614
|
+
: from;
|
|
615
|
+
/*
|
|
616
|
+
* A gentle curve, bowed AWAY from the live view. Straight lines read
|
|
617
|
+
* as lasers; and in the overview the middle is where the live view
|
|
618
|
+
* stands, so a road between districts goes around it rather than
|
|
619
|
+
* underneath it. The bow is sized to what actually needs clearing:
|
|
620
|
+
* a chord that would cross the view's box bows until its apex is
|
|
621
|
+
* outside it, and a chord that already misses keeps only a gentle
|
|
622
|
+
* arc — bowing everything as though it crossed left slack cables
|
|
623
|
+
* sagging across open ground.
|
|
624
|
+
*/
|
|
625
|
+
const midX = (from.x + to.x) / 2;
|
|
626
|
+
const midY = (from.y + to.y) / 2;
|
|
627
|
+
const dist = Math.hypot(to.x - from.x, to.y - from.y);
|
|
628
|
+
let bow = Math.min(overview ? 40 : 90, dist * (overview ? 0.09 : 0.16));
|
|
629
|
+
let nx = 0;
|
|
630
|
+
let ny = 0;
|
|
631
|
+
if (dist > 0) {
|
|
632
|
+
nx = -(to.y - from.y) / dist;
|
|
633
|
+
ny = (to.x - from.x) / dist;
|
|
634
|
+
let awayX = midX - result.width / 2;
|
|
635
|
+
let awayY = midY - result.height / 2;
|
|
636
|
+
if (stamp && !self) {
|
|
637
|
+
const cx = stamp.x + stamp.width / 2;
|
|
638
|
+
const cy = stamp.y + stamp.height / 2;
|
|
639
|
+
awayX = midX - cx;
|
|
640
|
+
awayY = midY - cy;
|
|
641
|
+
// How close the chord passes to the view's centre, against how
|
|
642
|
+
// far the view's corner reaches: the shortfall, doubled (the
|
|
643
|
+
// apex of a quadratic sits halfway to its control), is the bow
|
|
644
|
+
// that clears it.
|
|
645
|
+
const d = Math.abs(nx * (cx - from.x) + ny * (cy - from.y));
|
|
646
|
+
const reach = Math.hypot(stamp.width / 2, stamp.height / 2) * 0.85 + 24;
|
|
647
|
+
const along = ((cx - from.x) * (to.x - from.x) + (cy - from.y) * (to.y - from.y)) / (dist * dist);
|
|
648
|
+
if (d < reach && along > 0.1 && along < 0.9) {
|
|
649
|
+
bow = Math.min(180, Math.max(bow, (reach - d) * 2));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
if (nx * awayX + ny * awayY < 0) {
|
|
653
|
+
nx = -nx;
|
|
654
|
+
ny = -ny;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// A second relation between the same two ends bows beside the first.
|
|
658
|
+
bow += 2 * (fanned.get(strand.key) ?? 0);
|
|
659
|
+
// The route is the kit's call: the bowed arc, its chord, or two elbows.
|
|
660
|
+
const curve = routedQuadratic(kitLine.route, { p0: from, c: { x: midX + nx * bow, y: midY + ny * bow }, p1: to });
|
|
661
|
+
/* A road runs between two districts on the lattice; a line to the
|
|
662
|
+
picture standing in the middle keeps the kit's own route. */
|
|
663
|
+
const road = roadBetween(connector);
|
|
664
|
+
const orthogonal = kitLine.route === "orthogonal" || road;
|
|
665
|
+
const bends = (a, b) => (road ? latticePoints(a, b) : orthogonalPoints(a, b));
|
|
666
|
+
/*
|
|
667
|
+
* The visible runs: out of the box it starts in, into the box it
|
|
668
|
+
* ends in, and under any card it crosses between. A line with no
|
|
669
|
+
* run in the open — one drawing inside another — has nothing
|
|
670
|
+
* honest to show.
|
|
671
|
+
*/
|
|
672
|
+
/*
|
|
673
|
+
* TWO CHIPS OF ONE BAND take the gutters: a road through the grid
|
|
674
|
+
* that crosses nothing, so it is drawn whole rather than clipped to
|
|
675
|
+
* the pieces an arc left between the chips it ran under.
|
|
676
|
+
*/
|
|
677
|
+
const channelled = !self && strand.band ? channelRoute(fromBox, toBox, strand.band, lane) : null;
|
|
678
|
+
const runs = self || orthogonal || channelled ? [] : clipQuadratic(curve, [fromBox, toBox, ...strand.obstacles]);
|
|
679
|
+
const legs = channelled ? [channelled] : !self && orthogonal ? clipPolyline(bends(from, to), [fromBox, toBox, ...strand.obstacles]) : [];
|
|
680
|
+
if (!self && runs.length === 0 && legs.length === 0)
|
|
681
|
+
return null;
|
|
682
|
+
const lastLeg = legs[legs.length - 1];
|
|
683
|
+
const firstDrawn = orthogonal || channelled ? legs[0]?.[0] : runs[0]?.p0;
|
|
684
|
+
const lastDrawn = orthogonal || channelled ? lastLeg?.[lastLeg.length - 1] : runs[runs.length - 1]?.p1;
|
|
685
|
+
const quad = (segments) => segments
|
|
686
|
+
.map((run) => `M ${run.p0.x} ${run.p0.y} Q ${run.c.x} ${run.c.y} ${run.p1.x} ${run.p1.y}`)
|
|
687
|
+
.join(" ");
|
|
688
|
+
const loopD =
|
|
689
|
+
// An arc that leaves and returns: two arcs of the same
|
|
690
|
+
// circle, so it closes cleanly at any size.
|
|
691
|
+
`M ${anchor.x - radius} ${anchor.y} A ${radius} ${radius} 0 1 1 ${anchor.x + radius} ${anchor.y}` +
|
|
692
|
+
` A ${radius} ${radius} 0 0 1 ${anchor.x - radius} ${anchor.y}`;
|
|
693
|
+
const d = self ? loopD : channelled ? roundedPolylineD(channelled, 10) : orthogonal ? polylineD(legs) : quad(runs);
|
|
694
|
+
// The hit stroke also keeps out of the cards an end is drawn inside.
|
|
695
|
+
/*
|
|
696
|
+
* The hit corridor keeps OFF the cards by its own half-width: a
|
|
697
|
+
* road leaves a district at its border, and a fourteen-pixel
|
|
698
|
+
* corridor centred on that border sat seven pixels inside the
|
|
699
|
+
* card — so a press on the card's corner selected the road, and a
|
|
700
|
+
* double-click there travelled down it instead of into the kind.
|
|
701
|
+
*/
|
|
702
|
+
const clear = (box) => ({
|
|
703
|
+
x: box.x - 8,
|
|
704
|
+
y: box.y - 8,
|
|
705
|
+
width: box.width + 16,
|
|
706
|
+
height: box.height + 16,
|
|
707
|
+
});
|
|
708
|
+
const keptOff = [fromBox, toBox, ...strand.hosts, ...strand.obstacles].map(clear);
|
|
709
|
+
const hitD = self
|
|
710
|
+
? loopD
|
|
711
|
+
: channelled
|
|
712
|
+
? polylineD(legs)
|
|
713
|
+
: orthogonal
|
|
714
|
+
? polylineD(clipPolyline(bends(from, to), keptOff))
|
|
715
|
+
: quad(clipQuadratic(curve, keptOff));
|
|
716
|
+
const only = strand.edges.length === 1 ? strand.edges[0] : null;
|
|
717
|
+
const edgeId = only ? edgeSelectionId(connector.kind, only.from, only.to) : null;
|
|
718
|
+
const edgeChosen = edgeId !== null && selection.includes(edgeId);
|
|
719
|
+
const mine = strand.edges.some((edge) => chosenReal.has(edge.from) || chosenReal.has(edge.to));
|
|
720
|
+
const lit = !overview && chosenReal.size > 0 && mine;
|
|
721
|
+
const stressed = (emphasis !== null && connector.kind === emphasis) || edgeChosen;
|
|
722
|
+
/*
|
|
723
|
+
* FROM ALTITUDE THE ROADS ARE ON THE GROUND (the plots layer draws
|
|
724
|
+
* one per pair of districts, kerb to kerb). A line up here is drawn
|
|
725
|
+
* only when it says something the road cannot: the relation the
|
|
726
|
+
* legend is asking about, the edge that is chosen, a member of the
|
|
727
|
+
* selection, or a change that just happened. A focused screen's
|
|
728
|
+
* every member wired across the city was the picture this replaces.
|
|
729
|
+
*/
|
|
730
|
+
const live = liveOf?.(connector) !== undefined;
|
|
731
|
+
if (overview && !stressed && !mine && !(chosen.size > 0 && touches(connector)) && !live)
|
|
732
|
+
return null;
|
|
733
|
+
const opacity = overview
|
|
734
|
+
? altitudeOpacity({
|
|
735
|
+
emphasised: emphasis !== null,
|
|
736
|
+
stressed,
|
|
737
|
+
anyChosen: anyStrandChosen,
|
|
738
|
+
mine,
|
|
739
|
+
touches: touches(connector),
|
|
740
|
+
siblings: siblings.get(connector.id) ?? 1,
|
|
741
|
+
})
|
|
742
|
+
: stackOpacity({
|
|
743
|
+
edgeChosen,
|
|
744
|
+
lit,
|
|
745
|
+
// A relation the key is hovering lights the picture the same
|
|
746
|
+
// way a selection does, so the rest recedes for it too.
|
|
747
|
+
anyLit: anyStrandChosen || emphasis !== null,
|
|
748
|
+
own: style.opacity,
|
|
749
|
+
kit: kit.emphasis,
|
|
750
|
+
});
|
|
751
|
+
/*
|
|
752
|
+
* A lit line MARKS ITS FAR END, as a tie does: the destination is
|
|
753
|
+
* where the eye is being sent, and a dot says the line lands on
|
|
754
|
+
* something specific rather than trailing off.
|
|
755
|
+
*/
|
|
756
|
+
const far = lit && firstDrawn && lastDrawn
|
|
757
|
+
? strand.edges.some((edge) => chosenReal.has(edge.from))
|
|
758
|
+
? lastDrawn
|
|
759
|
+
: firstDrawn
|
|
760
|
+
: null;
|
|
761
|
+
const line = (_jsxs("g", { children: [_jsx("path", { "data-graview-connector": connector.kind, "data-graview-edges": strand.edges.length, "data-graview-lit": lit || undefined, "data-graview-activity": liveOf?.(connector)?.manner, opacity: opacity * (connector.opacity ?? 1), d: d, fill: "none", stroke: connectorStroke(style),
|
|
762
|
+
/*
|
|
763
|
+
* Above the stack the LINES ARE THE CONTENT.
|
|
764
|
+
*
|
|
765
|
+
* Inside the scene a connector is an aside — it says how the
|
|
766
|
+
* thing you are looking at is caught up in something else, and
|
|
767
|
+
* drawing it loudly would compete with the thing itself. From
|
|
768
|
+
* the Graview the shape of the domain IS the subject.
|
|
769
|
+
*/
|
|
770
|
+
strokeWidth: (lit ? Math.max(1.6, connectorWidth(style, overview)) : connectorWidth(style, overview)) +
|
|
771
|
+
(stressed ? 0.6 : 0), strokeDasharray: CONNECTOR_DASH[style.pattern], strokeLinecap: "round" }), far ? (_jsx("circle", { cx: far.x, cy: far.y, r: 2.6, fill: connectorStroke(style), opacity: opacity })) : null, self && overview ? (_jsx("text", { x: anchor.x, y: anchor.y - radius - 5, textAnchor: "middle", opacity: opacity, style: {
|
|
772
|
+
font: "9px var(--graview-font-body, system-ui)",
|
|
773
|
+
letterSpacing: "0.09em",
|
|
774
|
+
textTransform: "uppercase",
|
|
775
|
+
fill: "var(--graview-ink-faint)",
|
|
776
|
+
}, children: connector.kind.replace(/-/g, " ") })) : null] }, strand.key));
|
|
777
|
+
/*
|
|
778
|
+
* The HIT PATH for a line that stands for one edge: the SAME visible
|
|
779
|
+
* runs, hosted on a layer above the panels. Because the runs are
|
|
780
|
+
* already clipped to open ground, the pickable region is exactly
|
|
781
|
+
* what a person can see, and pressing a line never steals a card's
|
|
782
|
+
* click.
|
|
783
|
+
*/
|
|
784
|
+
let hit = null;
|
|
785
|
+
// A line the picture has faded to a ghost must not keep a click
|
|
786
|
+
// band: pickability follows visibility.
|
|
787
|
+
if (edgeId && onPickEdge && !self && opacity >= 0.2) {
|
|
788
|
+
hit = (_jsx("path", { "data-graview-edge": edgeId, className: "graview-edge-hit", d: hitD, fill: "none",
|
|
789
|
+
// The ends stay clear: a line lands ON a chip, and a hit stroke that reached
|
|
790
|
+
// the chip took the click meant for it. Seven percent off each end.
|
|
791
|
+
pathLength: 1, strokeDasharray: "0.86", strokeDashoffset: -0.07, stroke: "transparent", strokeWidth: 14, style: { pointerEvents: "stroke", cursor: "pointer" },
|
|
792
|
+
/*
|
|
793
|
+
* EITHER button opens the menu AT THE LINE. A left-click
|
|
794
|
+
* that only swapped the rail's contents changed the world
|
|
795
|
+
* quietly, three hundred pixels from the pointer — a line
|
|
796
|
+
* has no page to travel to, so its one meaning is "act on
|
|
797
|
+
* this relation, here".
|
|
798
|
+
*/
|
|
799
|
+
onClick: (event) => {
|
|
800
|
+
event.stopPropagation();
|
|
801
|
+
onPickEdge(edgeId, { x: event.clientX, y: event.clientY });
|
|
802
|
+
}, onContextMenu: (event) => {
|
|
803
|
+
event.preventDefault();
|
|
804
|
+
event.stopPropagation();
|
|
805
|
+
onPickEdge(edgeId, { x: event.clientX, y: event.clientY });
|
|
806
|
+
} }, `hit:${strand.key}`));
|
|
807
|
+
}
|
|
808
|
+
return { key: strand.key, line, hit };
|
|
809
|
+
});
|
|
810
|
+
return (_jsxs(_Fragment, { children: [_jsx("svg", { "aria-hidden": "true", "data-graview-world": "", width: result.width, height: result.height, style: {
|
|
811
|
+
position: "absolute",
|
|
812
|
+
left: 0,
|
|
813
|
+
top: 0,
|
|
814
|
+
pointerEvents: "none",
|
|
815
|
+
/*
|
|
816
|
+
* OVER the cards, on both paths.
|
|
817
|
+
*
|
|
818
|
+
* Behind them was free clipping on the DOM path and no clipping at
|
|
819
|
+
* all on the GPU path, whose canvas is opaque — and it hid the one
|
|
820
|
+
* stretch a line anchored inside a panel most needs to show, from
|
|
821
|
+
* the span to the panel's edge. Every run is clipped to open ground
|
|
822
|
+
* now, so sitting on top costs nothing the picture can see.
|
|
823
|
+
*/
|
|
824
|
+
zIndex: 2,
|
|
825
|
+
}, children: drawn.map((piece) => piece?.line) }), drawn.some((piece) => piece?.hit) ? (_jsx("svg", { "data-graview-world": "", width: result.width, height: result.height, style: {
|
|
826
|
+
position: "absolute",
|
|
827
|
+
left: 0,
|
|
828
|
+
top: 0,
|
|
829
|
+
pointerEvents: "none",
|
|
830
|
+
// Above the hosts (auto) but BELOW the scene chrome at 5 —
|
|
831
|
+
// the legend and the quick-select must win their own corners;
|
|
832
|
+
// the paths inside are clipped to open ground, so nothing that
|
|
833
|
+
// looks like a panel behaves like a line.
|
|
834
|
+
zIndex: 4,
|
|
835
|
+
}, children: drawn.map((piece) => piece?.hit) })) : null] }));
|
|
836
|
+
}
|
|
837
|
+
//# sourceMappingURL=connectors.js.map
|