structured-context-map-views 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +19 -2
- package/dist/definition-Cft28YUA.js +616 -0
- package/dist/definition-Cft28YUA.js.map +1 -0
- package/dist/index.js +1045 -0
- package/dist/index.js.map +1 -0
- package/dist/node.js +106 -0
- package/dist/node.js.map +1 -0
- package/dist/styles.css +528 -0
- package/dist/types/core/annotations.d.ts +47 -0
- package/dist/types/core/definition.d.ts +117 -0
- package/dist/types/core/format/definition-schema.d.ts +181 -0
- package/dist/types/core/format/index.d.ts +450 -0
- package/dist/types/core/format/landscape-layout.d.ts +102 -0
- package/dist/types/core/format/param-ref.d.ts +10 -0
- package/dist/types/core/format/schema-url.d.ts +7 -0
- package/dist/types/core/format/settings.d.ts +41 -0
- package/dist/types/core/format/tree-layout.d.ts +40 -0
- package/dist/types/core/format/xy-layout.d.ts +94 -0
- package/dist/types/core/graph.d.ts +16 -0
- package/dist/types/core/landscape.d.ts +44 -0
- package/dist/types/core/occurrence.d.ts +11 -0
- package/dist/types/core/tree.d.ts +45 -0
- package/dist/types/core/xy.d.ts +43 -0
- package/dist/types/index.d.ts +10 -0
- package/dist/types/layouts/EdgeOverlay.d.ts +36 -0
- package/dist/types/layouts/LandscapeLayout.d.ts +8 -0
- package/dist/types/layouts/MapView.d.ts +18 -0
- package/dist/types/layouts/NodeCard.d.ts +16 -0
- package/dist/types/layouts/ParameterControls.d.ts +13 -0
- package/dist/types/layouts/ParameterInputs.d.ts +21 -0
- package/dist/types/layouts/TreeLayout.d.ts +16 -0
- package/dist/types/layouts/XYLayout.d.ts +9 -0
- package/dist/types/layouts/annotations-context.d.ts +4 -0
- package/dist/types/layouts/useParameterControls.d.ts +13 -0
- package/dist/types/node/bundled-definitions.d.ts +1 -0
- package/dist/types/node/index.d.ts +17 -0
- package/package.json +69 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
import { a as resolveRegions, c as MAP_VIEW_SCHEMAS, i as resolveParameterValues, n as mapViewsForSchema, o as selectionFilter, r as parseMapViewDefinition, s as viewValidationLevel, t as MapViewDefinitionError } from "./definition-Cft28YUA.js";
|
|
2
|
+
import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region src/core/annotations.ts
|
|
5
|
+
/**
|
|
6
|
+
* Attach validation results to the nodes of `graph`, by node id. Results that can't be attached are
|
|
7
|
+
* returned in `unattached` for the host to surface: global-scope rules (no node), and nodes that
|
|
8
|
+
* aren't in the graph, which includes every node that failed its schema.
|
|
9
|
+
*/
|
|
10
|
+
function annotationsFrom(input, graph) {
|
|
11
|
+
const annotations = /* @__PURE__ */ new Map();
|
|
12
|
+
const unattached = [];
|
|
13
|
+
const add = (nodeId, annotation, location) => {
|
|
14
|
+
if (nodeId !== void 0 && graph.nodes.has(nodeId)) annotations.set(nodeId, [...annotations.get(nodeId) ?? [], annotation]);
|
|
15
|
+
else unattached.push({
|
|
16
|
+
...annotation,
|
|
17
|
+
nodeId,
|
|
18
|
+
...location && { location }
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
for (const { nodeId, source, issues } of input.nodeErrors ?? []) for (const issue of issues) add(nodeId, {
|
|
22
|
+
severity: "error",
|
|
23
|
+
source: "schema",
|
|
24
|
+
message: issue.message
|
|
25
|
+
}, source);
|
|
26
|
+
for (const { nodeId, description } of input.hierarchyViolations ?? []) add(nodeId, {
|
|
27
|
+
severity: "error",
|
|
28
|
+
source: "hierarchy",
|
|
29
|
+
message: description
|
|
30
|
+
});
|
|
31
|
+
for (const { nodeId, description, severity, ruleId } of input.ruleViolations ?? []) add(nodeId, {
|
|
32
|
+
severity,
|
|
33
|
+
source: "rule",
|
|
34
|
+
message: description,
|
|
35
|
+
ruleId
|
|
36
|
+
});
|
|
37
|
+
return {
|
|
38
|
+
annotations,
|
|
39
|
+
unattached
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
var RANK = {
|
|
43
|
+
error: 0,
|
|
44
|
+
warning: 1,
|
|
45
|
+
info: 2
|
|
46
|
+
};
|
|
47
|
+
/** The most serious severity among annotations, or undefined if there are none. */
|
|
48
|
+
function worstSeverity(annotations) {
|
|
49
|
+
return annotations.reduce((worst, { severity }) => worst === void 0 || RANK[severity] < RANK[worst] ? severity : worst, void 0);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The annotations a map view shows: those at `level` or more serious, from `sources`. `off` shows none.
|
|
53
|
+
* Nodes left with no annotations are dropped from the index.
|
|
54
|
+
*/
|
|
55
|
+
function filterAnnotations(index, level, sources) {
|
|
56
|
+
if (level === "off") return /* @__PURE__ */ new Map();
|
|
57
|
+
const filtered = /* @__PURE__ */ new Map();
|
|
58
|
+
for (const [id, annotations] of index) {
|
|
59
|
+
const kept = annotations.filter((a) => RANK[a.severity] <= RANK[level] && sources.includes(a.source));
|
|
60
|
+
if (kept.length > 0) filtered.set(id, kept);
|
|
61
|
+
}
|
|
62
|
+
return filtered;
|
|
63
|
+
}
|
|
64
|
+
/** The annotations a map view shows, already narrowed by its validation settings. Read by node cards. */
|
|
65
|
+
var AnnotationsContext = createContext(/* @__PURE__ */ new Map());
|
|
66
|
+
var useAnnotations = () => useContext(AnnotationsContext);
|
|
67
|
+
//#endregion
|
|
68
|
+
//#region src/core/graph.ts
|
|
69
|
+
var NONE = [];
|
|
70
|
+
/**
|
|
71
|
+
* Edges to a node's parents, in the order the node declares them. A graph narrowed by a filter keeps
|
|
72
|
+
* edges to parents outside it; those are left out.
|
|
73
|
+
*/
|
|
74
|
+
function parentEdges(graph, id) {
|
|
75
|
+
return (graph.parentEdges.get(id) ?? NONE).filter((edge) => graph.nodes.has(edge.parent));
|
|
76
|
+
}
|
|
77
|
+
/** Edges to a node's children, in graph order of the children. */
|
|
78
|
+
function childEdges(graph, id) {
|
|
79
|
+
return graph.childEdges.get(id) ?? NONE;
|
|
80
|
+
}
|
|
81
|
+
/** Every edge whose parent and child are both in the graph. */
|
|
82
|
+
function edgesWithin(graph) {
|
|
83
|
+
return [...graph.nodes.keys()].flatMap((id) => parentEdges(graph, id));
|
|
84
|
+
}
|
|
85
|
+
/** Whether an edge belongs to the given hierarchy (as opposed to a relationship or another hierarchy). */
|
|
86
|
+
function isHierarchyEdge(edge, hierarchy) {
|
|
87
|
+
return hierarchy !== void 0 && edge.kind === "hierarchy" && edge.hierarchy === hierarchy.name;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/core/occurrence.ts
|
|
91
|
+
/**
|
|
92
|
+
* Path-based key for one occurrence of a node: region, then the steps from the outermost
|
|
93
|
+
* container down to the node. Unique within a map view even when a node appears several times.
|
|
94
|
+
* Hosts can render several map views at once, so layouts scope keys to the map view instance.
|
|
95
|
+
*/
|
|
96
|
+
function occurrenceKey(regionId, path) {
|
|
97
|
+
return JSON.stringify([regionId, ...path]);
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/core/landscape.ts
|
|
101
|
+
/**
|
|
102
|
+
* Lay a space out as nested containers following the layout's grouping levels.
|
|
103
|
+
*
|
|
104
|
+
* Adjacent node levels are joined by any edge between their types, in either direction, or only
|
|
105
|
+
* edges from `edge.field`. A `via` chain joins them through the skipped types. A field level
|
|
106
|
+
* splits the next node level by that field's values. A node reached from several containers
|
|
107
|
+
* appears in each, in full: the levels are finite, so repeating contents can't recurse.
|
|
108
|
+
*/
|
|
109
|
+
function buildLandscape(graph, regionId, layout) {
|
|
110
|
+
const levels = layout.group;
|
|
111
|
+
const order = new Map([...graph.nodes.keys()].map((title, i) => [title, i]));
|
|
112
|
+
const first = /* @__PURE__ */ new Map();
|
|
113
|
+
const occurrenceFor = (candidate, levelIdx, path) => {
|
|
114
|
+
const { node } = candidate;
|
|
115
|
+
const nodePath = [...path, node.id];
|
|
116
|
+
const key = occurrenceKey(regionId, nodePath);
|
|
117
|
+
const repeatOf = first.get(node.id);
|
|
118
|
+
if (repeatOf === void 0) first.set(node.id, key);
|
|
119
|
+
return {
|
|
120
|
+
key,
|
|
121
|
+
level: levelIdx,
|
|
122
|
+
item: {
|
|
123
|
+
kind: "node",
|
|
124
|
+
node
|
|
125
|
+
},
|
|
126
|
+
via: candidate.via,
|
|
127
|
+
...repeatOf !== void 0 && { repeatOf },
|
|
128
|
+
children: place(levelIdx + 1, node, [], nodePath)
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
/** Occurrences for level `levelIdx` inside `anchor`, the nearest enclosing node. */
|
|
132
|
+
const place = (levelIdx, anchor, filters, path) => {
|
|
133
|
+
const level = levels[levelIdx];
|
|
134
|
+
if (level === void 0) return [];
|
|
135
|
+
if (isTypeLevel(level)) return candidates(level, anchor, filters).map((c) => occurrenceFor(c, levelIdx, path));
|
|
136
|
+
const nodeLevel = levels.slice(levelIdx).find(isTypeLevel);
|
|
137
|
+
return distinctValues(candidates(nodeLevel, anchor, filters).map((c) => c.node), level.field).map((value) => {
|
|
138
|
+
const valuePath = [...path, [level.field, value]];
|
|
139
|
+
return {
|
|
140
|
+
key: occurrenceKey(regionId, valuePath),
|
|
141
|
+
level: levelIdx,
|
|
142
|
+
item: {
|
|
143
|
+
kind: "value",
|
|
144
|
+
field: level.field,
|
|
145
|
+
value
|
|
146
|
+
},
|
|
147
|
+
via: [],
|
|
148
|
+
children: place(levelIdx + 1, anchor, [...filters, {
|
|
149
|
+
field: level.field,
|
|
150
|
+
value
|
|
151
|
+
}], valuePath)
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
};
|
|
155
|
+
/** Nodes of a type level joined to `anchor` (all of the type at the top) and matching the filters. */
|
|
156
|
+
const candidates = (level, anchor, filters) => {
|
|
157
|
+
return (anchor === void 0 ? ofType(level.type).map((node) => ({
|
|
158
|
+
node,
|
|
159
|
+
via: []
|
|
160
|
+
})) : walkChain(anchor, level.via ?? [], level.type, level.edge?.field)).filter(({ node }) => filters.every((f) => hasValue(node, f)));
|
|
161
|
+
};
|
|
162
|
+
const ofType = (type) => [...graph.nodes.values()].filter((node) => node.resolvedType === type);
|
|
163
|
+
/** Neighbours of a node with the given type, across edges in either direction, in space order. */
|
|
164
|
+
const neighbours = (title, type, field) => {
|
|
165
|
+
return [.../* @__PURE__ */ new Set([...childEdges(graph, title).filter((e) => field === void 0 || e.field === field).map((e) => e.child), ...parentEdges(graph, title).filter((e) => field === void 0 || e.field === field).map((e) => e.parent)])].map((t) => graph.nodes.get(t)).filter((node) => node.resolvedType === type).sort((a, b) => order.get(a.id) - order.get(b.id));
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Follow edges from `anchor` through each `via` type to `target`, keeping the skipped nodes on
|
|
169
|
+
* the way to each target node. Several chains to one target merge into one candidate.
|
|
170
|
+
*/
|
|
171
|
+
const walkChain = (anchor, via, target, field) => {
|
|
172
|
+
let frontier = [{
|
|
173
|
+
node: anchor,
|
|
174
|
+
trail: []
|
|
175
|
+
}];
|
|
176
|
+
for (const type of via) frontier = frontier.flatMap(({ node, trail }) => neighbours(node.id, type, void 0).map((next) => ({
|
|
177
|
+
node: next,
|
|
178
|
+
trail: [...trail, next]
|
|
179
|
+
})));
|
|
180
|
+
const byTitle = /* @__PURE__ */ new Map();
|
|
181
|
+
for (const { node, trail } of frontier) for (const next of neighbours(node.id, target, field)) {
|
|
182
|
+
const candidate = byTitle.get(next.id) ?? {
|
|
183
|
+
node: next,
|
|
184
|
+
via: via.map((type) => ({
|
|
185
|
+
type,
|
|
186
|
+
nodes: []
|
|
187
|
+
}))
|
|
188
|
+
};
|
|
189
|
+
trail.forEach((skipped, i) => {
|
|
190
|
+
const step = candidate.via[i];
|
|
191
|
+
if (!step.nodes.includes(skipped)) step.nodes.push(skipped);
|
|
192
|
+
});
|
|
193
|
+
byTitle.set(next.id, candidate);
|
|
194
|
+
}
|
|
195
|
+
return [...byTitle.values()].sort((a, b) => order.get(a.node.id) - order.get(b.node.id));
|
|
196
|
+
};
|
|
197
|
+
const groups = place(0, void 0, [], []);
|
|
198
|
+
const ungrouped = [];
|
|
199
|
+
levels.forEach((level, levelIdx) => {
|
|
200
|
+
if (levelIdx === 0 || !isTypeLevel(level)) return;
|
|
201
|
+
for (const node of ofType(level.type)) if (!first.has(node.id)) ungrouped.push(occurrenceFor({
|
|
202
|
+
node,
|
|
203
|
+
via: []
|
|
204
|
+
}, levelIdx, [null]));
|
|
205
|
+
});
|
|
206
|
+
return {
|
|
207
|
+
groups,
|
|
208
|
+
ungrouped
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function isTypeLevel(level) {
|
|
212
|
+
return "type" in level;
|
|
213
|
+
}
|
|
214
|
+
/** A field's values as group labels: strings, an entry per array item, `null` when empty. */
|
|
215
|
+
function valuesOf(node, field) {
|
|
216
|
+
const raw = node.schemaData[field];
|
|
217
|
+
const list = (Array.isArray(raw) ? raw : [raw]).filter((v) => v !== void 0 && v !== null && v !== "");
|
|
218
|
+
return list.length === 0 ? [null] : list.map((v) => typeof v === "object" ? JSON.stringify(v) : String(v));
|
|
219
|
+
}
|
|
220
|
+
/** Distinct values in first-seen order, with the empty group last. */
|
|
221
|
+
function distinctValues(nodes, field) {
|
|
222
|
+
const values = new Set(nodes.flatMap((node) => valuesOf(node, field)));
|
|
223
|
+
const present = [...values].filter((v) => v !== null);
|
|
224
|
+
return values.has(null) ? [...present, null] : present;
|
|
225
|
+
}
|
|
226
|
+
function hasValue(node, { field, value }) {
|
|
227
|
+
return valuesOf(node, field).includes(value);
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/layouts/NodeCard.tsx
|
|
231
|
+
/**
|
|
232
|
+
* A clickable node, drawn as a card (title plus selected fields) or a compact shape with a
|
|
233
|
+
* tooltip. Deliberately a `div` with button semantics rather than a `<button>`: hosts style
|
|
234
|
+
* native form controls globally (Obsidian fixes button height and disables wrapping).
|
|
235
|
+
*/
|
|
236
|
+
function NodeCard({ node, repeat, nodes, onSelect }) {
|
|
237
|
+
const select = () => onSelect?.(node);
|
|
238
|
+
const annotations = useAnnotations().get(node.id);
|
|
239
|
+
const label = `${node.resolvedType}: ${node.title}${repeat ? " (shown in full elsewhere)" : ""}${annotationsLabel(annotations)}`;
|
|
240
|
+
const onKeyDown = (e) => {
|
|
241
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
select();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const border = repeat ? "mv:border-dashed mv:border-muted/60 mv:opacity-75" : "mv:border-edge";
|
|
247
|
+
if (nodes.style === "shape") return /* @__PURE__ */ jsxs("div", {
|
|
248
|
+
role: "button",
|
|
249
|
+
tabIndex: 0,
|
|
250
|
+
title: label,
|
|
251
|
+
onClick: select,
|
|
252
|
+
onKeyDown,
|
|
253
|
+
"aria-label": label,
|
|
254
|
+
className: `mv:relative mv:flex mv:size-6 mv:cursor-pointer mv:items-center mv:justify-center mv:rounded-full mv:border-2 mv:bg-card mv:text-[0.65rem] mv:font-semibold mv:uppercase mv:text-muted mv:hover:border-accent mv:focus-visible:outline-2 mv:focus-visible:outline-accent ${border}`,
|
|
255
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
256
|
+
"aria-hidden": "true",
|
|
257
|
+
children: node.resolvedType.charAt(0)
|
|
258
|
+
}), /* @__PURE__ */ jsx(AnnotationBadge, { annotations })]
|
|
259
|
+
});
|
|
260
|
+
const fields = nodes.fields.map((field) => [field, formatValue(node.schemaData[field])]).filter(([, value]) => value !== void 0);
|
|
261
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
262
|
+
role: "button",
|
|
263
|
+
tabIndex: 0,
|
|
264
|
+
title: label,
|
|
265
|
+
onClick: select,
|
|
266
|
+
onKeyDown,
|
|
267
|
+
className: `mv:relative mv:w-44 mv:cursor-pointer mv:rounded-md mv:border mv:bg-card mv:px-2.5 mv:py-1.5 mv:text-left mv:shadow-sm mv:hover:border-accent mv:focus-visible:outline-2 mv:focus-visible:outline-accent ${border}`,
|
|
268
|
+
children: [
|
|
269
|
+
/* @__PURE__ */ jsxs("span", {
|
|
270
|
+
className: "mv:block mv:text-[0.65rem] mv:uppercase mv:tracking-wide mv:text-muted",
|
|
271
|
+
children: [node.resolvedType, repeat && " · repeat"]
|
|
272
|
+
}),
|
|
273
|
+
/* @__PURE__ */ jsx("span", {
|
|
274
|
+
className: "mv:block mv:text-sm mv:leading-snug",
|
|
275
|
+
children: node.title
|
|
276
|
+
}),
|
|
277
|
+
fields.length > 0 && /* @__PURE__ */ jsx("dl", {
|
|
278
|
+
className: "mv:m-0 mv:mt-1 mv:text-xs mv:text-muted",
|
|
279
|
+
children: fields.map(([field, value]) => /* @__PURE__ */ jsxs("div", {
|
|
280
|
+
className: "mv:flex mv:gap-1",
|
|
281
|
+
children: [/* @__PURE__ */ jsxs("dt", {
|
|
282
|
+
className: "mv:shrink-0",
|
|
283
|
+
children: [field, ":"]
|
|
284
|
+
}), /* @__PURE__ */ jsx("dd", {
|
|
285
|
+
className: "mv:m-0 mv:truncate mv:text-ink",
|
|
286
|
+
children: value
|
|
287
|
+
})]
|
|
288
|
+
}, field))
|
|
289
|
+
}),
|
|
290
|
+
/* @__PURE__ */ jsx(AnnotationBadge, { annotations })
|
|
291
|
+
]
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
var BADGE = {
|
|
295
|
+
error: "mv:bg-error",
|
|
296
|
+
warning: "mv:bg-warning",
|
|
297
|
+
info: "mv:bg-info"
|
|
298
|
+
};
|
|
299
|
+
/** A count of the node's validation results in the corner, coloured by the most serious. Decorative: the count is in the card's label. */
|
|
300
|
+
function AnnotationBadge({ annotations }) {
|
|
301
|
+
const worst = annotations && worstSeverity(annotations);
|
|
302
|
+
if (!annotations || !worst) return null;
|
|
303
|
+
return /* @__PURE__ */ jsx("span", {
|
|
304
|
+
"aria-hidden": "true",
|
|
305
|
+
className: `mv:absolute mv:-top-1.5 mv:-right-1.5 mv:flex mv:h-4 mv:min-w-4 mv:items-center mv:justify-center mv:rounded-full mv:px-1 mv:text-[0.6rem] mv:font-semibold mv:text-white ${BADGE[worst]}`,
|
|
306
|
+
children: annotations.length
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
function annotationsLabel(annotations) {
|
|
310
|
+
const worst = annotations && worstSeverity(annotations);
|
|
311
|
+
if (!annotations || !worst) return "";
|
|
312
|
+
return ` (${annotations.length} validation ${annotations.length === 1 ? "result" : "results"}, most serious: ${worst})`;
|
|
313
|
+
}
|
|
314
|
+
function formatValue(value) {
|
|
315
|
+
if (value === void 0 || value === null || value === "") return void 0;
|
|
316
|
+
if (Array.isArray(value)) return value.map(String).join(", ");
|
|
317
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
318
|
+
return String(value);
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/layouts/LandscapeLayout.tsx
|
|
322
|
+
/**
|
|
323
|
+
* Nested containers following the layout's grouping levels; the last level is drawn as nodes.
|
|
324
|
+
* Flexbox does the nesting and wrapping. Containment shows the grouping edges, so no connectors
|
|
325
|
+
* are drawn. PROTOTYPE: `edges.show` has no effect here; other edges aren't drawn yet.
|
|
326
|
+
*/
|
|
327
|
+
function LandscapeLayout({ graph, region, instanceId, onNodeSelect }) {
|
|
328
|
+
const { layout } = region;
|
|
329
|
+
const landscape = useMemo(() => buildLandscape(graph, region.id, layout), [
|
|
330
|
+
graph,
|
|
331
|
+
region.id,
|
|
332
|
+
layout
|
|
333
|
+
]);
|
|
334
|
+
const last = layout.group.length - 1;
|
|
335
|
+
const breadcrumb = (occurrence) => {
|
|
336
|
+
const level = layout.group[occurrence.level];
|
|
337
|
+
if (!level || !("type" in level) || level.intermediate !== "breadcrumb") return null;
|
|
338
|
+
const steps = occurrence.via.filter((step) => step.nodes.length > 0);
|
|
339
|
+
if (steps.length === 0) return null;
|
|
340
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
341
|
+
className: "mv:block mv:max-w-44 mv:text-[0.65rem] mv:leading-tight mv:text-muted",
|
|
342
|
+
children: ["via ", steps.map((step) => step.nodes.map((n) => n.title).join(", ")).join(" › ")]
|
|
343
|
+
});
|
|
344
|
+
};
|
|
345
|
+
const render = (occurrence, direction) => {
|
|
346
|
+
const repeat = occurrence.repeatOf !== void 0;
|
|
347
|
+
const marker = { "data-occurrence": `${instanceId}${occurrence.key}` };
|
|
348
|
+
if (occurrence.item.kind === "node" && occurrence.level === last) return /* @__PURE__ */ jsxs("li", {
|
|
349
|
+
...marker,
|
|
350
|
+
className: "mv:flex mv:flex-col mv:gap-0.5",
|
|
351
|
+
children: [/* @__PURE__ */ jsx(NodeCard, {
|
|
352
|
+
node: occurrence.item.node,
|
|
353
|
+
repeat,
|
|
354
|
+
nodes: region.nodes,
|
|
355
|
+
onSelect: onNodeSelect
|
|
356
|
+
}), breadcrumb(occurrence)]
|
|
357
|
+
}, occurrence.key);
|
|
358
|
+
const inner = direction === "horizontal" ? "vertical" : "horizontal";
|
|
359
|
+
const holdsNodes = occurrence.level + 1 === last;
|
|
360
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
361
|
+
...marker,
|
|
362
|
+
className: `mv:flex mv:flex-col mv:gap-2 mv:rounded-lg mv:border mv:bg-surface mv:p-2 ${repeat ? "mv:border-dashed mv:border-muted/60" : "mv:border-edge"}`,
|
|
363
|
+
children: [occurrence.item.kind === "node" ? /* @__PURE__ */ jsx(ContainerHeading, {
|
|
364
|
+
node: occurrence.item.node,
|
|
365
|
+
repeat,
|
|
366
|
+
onSelect: onNodeSelect,
|
|
367
|
+
children: breadcrumb(occurrence)
|
|
368
|
+
}) : /* @__PURE__ */ jsxs("span", {
|
|
369
|
+
className: "mv:text-xs mv:font-semibold mv:text-muted",
|
|
370
|
+
children: [
|
|
371
|
+
occurrence.item.field,
|
|
372
|
+
": ",
|
|
373
|
+
occurrence.item.value === null ? "(none)" : displayValue(occurrence.item.value)
|
|
374
|
+
]
|
|
375
|
+
}), /* @__PURE__ */ jsx(Items, {
|
|
376
|
+
direction: holdsNodes ? "wrap" : inner,
|
|
377
|
+
children: occurrence.children.map((child) => render(child, inner))
|
|
378
|
+
})]
|
|
379
|
+
}, occurrence.key);
|
|
380
|
+
};
|
|
381
|
+
const top = layout.orientation;
|
|
382
|
+
return /* @__PURE__ */ jsx("div", {
|
|
383
|
+
className: "mv:w-max mv:min-w-full mv:p-4",
|
|
384
|
+
children: /* @__PURE__ */ jsxs(Items, {
|
|
385
|
+
direction: last === 0 ? "wrap" : top,
|
|
386
|
+
children: [landscape.groups.map((group) => render(group, top)), landscape.ungrouped.length > 0 && /* @__PURE__ */ jsxs("li", {
|
|
387
|
+
className: "mv:flex mv:flex-col mv:gap-2 mv:rounded-lg mv:border mv:border-dashed mv:border-edge mv:p-2",
|
|
388
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
389
|
+
className: "mv:text-xs mv:font-semibold mv:text-muted",
|
|
390
|
+
children: "Ungrouped"
|
|
391
|
+
}), /* @__PURE__ */ jsx(Items, {
|
|
392
|
+
direction: "wrap",
|
|
393
|
+
children: landscape.ungrouped.map((o) => render(o, top))
|
|
394
|
+
})]
|
|
395
|
+
})]
|
|
396
|
+
})
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function Items({ direction, children }) {
|
|
400
|
+
const layout = {
|
|
401
|
+
horizontal: "mv:flex-row mv:items-start mv:gap-3",
|
|
402
|
+
vertical: "mv:flex-col mv:items-stretch mv:gap-2",
|
|
403
|
+
wrap: "mv:max-w-[36rem] mv:flex-row mv:flex-wrap mv:items-start mv:gap-2"
|
|
404
|
+
}[direction];
|
|
405
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
406
|
+
className: `mv:m-0 mv:flex mv:list-none mv:p-0 ${layout}`,
|
|
407
|
+
children
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** A container's node: type and title, selectable like a card. */
|
|
411
|
+
function ContainerHeading({ node, repeat, onSelect, children }) {
|
|
412
|
+
const select = () => onSelect?.(node);
|
|
413
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
414
|
+
role: "button",
|
|
415
|
+
tabIndex: 0,
|
|
416
|
+
title: `${node.resolvedType}: ${node.title}${repeat ? " (also shown elsewhere)" : ""}`,
|
|
417
|
+
onClick: select,
|
|
418
|
+
onKeyDown: (e) => {
|
|
419
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
420
|
+
e.preventDefault();
|
|
421
|
+
select();
|
|
422
|
+
}
|
|
423
|
+
},
|
|
424
|
+
className: "mv:cursor-pointer mv:rounded mv:px-1 mv:hover:text-accent mv:focus-visible:outline-2 mv:focus-visible:outline-accent",
|
|
425
|
+
children: [
|
|
426
|
+
/* @__PURE__ */ jsxs("span", {
|
|
427
|
+
className: "mv:block mv:text-[0.65rem] mv:uppercase mv:tracking-wide mv:text-muted",
|
|
428
|
+
children: [node.resolvedType, repeat && " · repeat"]
|
|
429
|
+
}),
|
|
430
|
+
/* @__PURE__ */ jsx("span", {
|
|
431
|
+
className: "mv:block mv:text-sm mv:font-semibold mv:leading-snug",
|
|
432
|
+
children: node.title
|
|
433
|
+
}),
|
|
434
|
+
children
|
|
435
|
+
]
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
/** Field values that are wikilinks show as the linked title. */
|
|
439
|
+
function displayValue(value) {
|
|
440
|
+
return value.replace(/^\[\[([^\]|]+)(\|[^\]]*)?\]\]$/, "$1");
|
|
441
|
+
}
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region src/core/tree.ts
|
|
444
|
+
/**
|
|
445
|
+
* Lay a space out as a tree over its hierarchy.
|
|
446
|
+
*
|
|
447
|
+
* Roots are root-type nodes with no hierarchy parent. Traversal is depth-first in space order,
|
|
448
|
+
* so a node's primary occurrence is under the parent reached first. Under its other parents a
|
|
449
|
+
* node is either repeated without its subtree (`repeat`), or left out and linked by a cross-edge
|
|
450
|
+
* (`cross-edge`). Either way traversal stops there, which also keeps cycles from recursing.
|
|
451
|
+
*/
|
|
452
|
+
function buildTree(graph, regionId, dag = "repeat") {
|
|
453
|
+
const { hierarchy } = graph;
|
|
454
|
+
const levelTypes = new Set(hierarchy?.levels.map((level) => level.type));
|
|
455
|
+
const rootType = hierarchy?.levels[0]?.type;
|
|
456
|
+
const primary = /* @__PURE__ */ new Map();
|
|
457
|
+
const pendingCross = [];
|
|
458
|
+
const hierarchyParents = (title) => parentEdges(graph, title).filter((edge) => isHierarchyEdge(edge, hierarchy));
|
|
459
|
+
const visit = (node, path) => {
|
|
460
|
+
const nodePath = [...path, node.id];
|
|
461
|
+
const key = occurrenceKey(regionId, nodePath);
|
|
462
|
+
primary.set(node.id, key);
|
|
463
|
+
const childTitles = new Set(childEdges(graph, node.id).filter((edge) => isHierarchyEdge(edge, hierarchy)).map((edge) => edge.child));
|
|
464
|
+
const children = [];
|
|
465
|
+
for (const title of childTitles) {
|
|
466
|
+
const child = graph.nodes.get(title);
|
|
467
|
+
const repeatOf = primary.get(title);
|
|
468
|
+
if (repeatOf === void 0) children.push(visit(child, nodePath));
|
|
469
|
+
else if (dag === "repeat") children.push({
|
|
470
|
+
key: occurrenceKey(regionId, [...nodePath, title]),
|
|
471
|
+
node: child,
|
|
472
|
+
children: [],
|
|
473
|
+
repeatOf
|
|
474
|
+
});
|
|
475
|
+
else pendingCross.push({
|
|
476
|
+
from: key,
|
|
477
|
+
child: title
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
return {
|
|
481
|
+
key,
|
|
482
|
+
node,
|
|
483
|
+
children
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
const levelNodes = [...graph.nodes.values()].filter((node) => levelTypes.has(node.resolvedType));
|
|
487
|
+
const unparented = levelNodes.filter((node) => hierarchyParents(node.id).length === 0);
|
|
488
|
+
const roots = unparented.filter((node) => node.resolvedType === rootType).map((node) => visit(node, []));
|
|
489
|
+
const orphans = unparented.filter((node) => node.resolvedType !== rootType).map((node) => visit(node, []));
|
|
490
|
+
for (const node of levelNodes) if (!primary.has(node.id)) orphans.push(visit(node, []));
|
|
491
|
+
return {
|
|
492
|
+
roots,
|
|
493
|
+
orphans,
|
|
494
|
+
primary,
|
|
495
|
+
crossLinks: pendingCross.map(({ from, child }) => ({
|
|
496
|
+
from,
|
|
497
|
+
to: primary.get(child),
|
|
498
|
+
kind: "cross"
|
|
499
|
+
}))
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/** Parent → child pairs of occurrence keys: one per rendered parent-child link, then cross-links. */
|
|
503
|
+
function treeLinks(tree) {
|
|
504
|
+
const links = [];
|
|
505
|
+
const walk = (occurrence) => {
|
|
506
|
+
for (const child of occurrence.children) {
|
|
507
|
+
links.push({
|
|
508
|
+
from: occurrence.key,
|
|
509
|
+
to: child.key,
|
|
510
|
+
kind: "tree"
|
|
511
|
+
});
|
|
512
|
+
walk(child);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
for (const root of [...tree.roots, ...tree.orphans]) walk(root);
|
|
516
|
+
return [...links, ...tree.crossLinks];
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/layouts/EdgeOverlay.tsx
|
|
520
|
+
/**
|
|
521
|
+
* SVG layer drawing connectors between rendered occurrences.
|
|
522
|
+
*
|
|
523
|
+
* Measures occurrence elements after layout and redraws when the container or any occurrence
|
|
524
|
+
* resizes, so it follows embedded panes that resize independently of the window, and reflows
|
|
525
|
+
* such as late font loads. The SVG fills the container instead of being sized from it, so it
|
|
526
|
+
* never holds the container open after the layout shrinks.
|
|
527
|
+
*/
|
|
528
|
+
function EdgeOverlay({ links, elements, container, routing }) {
|
|
529
|
+
const [paths, setPaths] = useState([]);
|
|
530
|
+
useLayoutEffect(() => {
|
|
531
|
+
if (!container) return;
|
|
532
|
+
const measure = () => {
|
|
533
|
+
const origin = container.getBoundingClientRect();
|
|
534
|
+
const paths = [];
|
|
535
|
+
for (const { from, to, kind, id, lane = 0 } of links) {
|
|
536
|
+
const a = elements.get(from)?.getBoundingClientRect();
|
|
537
|
+
const b = elements.get(to)?.getBoundingClientRect();
|
|
538
|
+
if (!a || !b) continue;
|
|
539
|
+
paths.push({
|
|
540
|
+
key: `${from}→${to}${id ?? ""}`,
|
|
541
|
+
d: routing === "straight" ? line(a, b, origin, lane) : connector(a, b, origin, routing),
|
|
542
|
+
cross: kind === "cross"
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
setPaths(paths);
|
|
546
|
+
};
|
|
547
|
+
measure();
|
|
548
|
+
const observer = new ResizeObserver(measure);
|
|
549
|
+
observer.observe(container);
|
|
550
|
+
for (const element of elements.values()) observer.observe(element);
|
|
551
|
+
return () => observer.disconnect();
|
|
552
|
+
}, [
|
|
553
|
+
links,
|
|
554
|
+
elements,
|
|
555
|
+
container,
|
|
556
|
+
routing
|
|
557
|
+
]);
|
|
558
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
559
|
+
"aria-hidden": "true",
|
|
560
|
+
className: "mv:pointer-events-none mv:absolute mv:inset-0 mv:h-full mv:w-full mv:overflow-visible",
|
|
561
|
+
children: paths.map(({ key, d, cross }) => /* @__PURE__ */ jsx("path", {
|
|
562
|
+
d,
|
|
563
|
+
className: `mv:fill-none ${cross ? "mv:stroke-accent/60" : "mv:stroke-muted/50"}`,
|
|
564
|
+
strokeWidth: 1.5,
|
|
565
|
+
strokeDasharray: cross ? "4 3" : void 0
|
|
566
|
+
}, key))
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
/** Cubic curve from the parent's trailing edge to the child's leading edge. */
|
|
570
|
+
function connector(a, b, origin, orientation) {
|
|
571
|
+
if (orientation === "vertical") {
|
|
572
|
+
const x1 = a.left + a.width / 2 - origin.left;
|
|
573
|
+
const y1 = a.bottom - origin.top;
|
|
574
|
+
const x2 = b.left + b.width / 2 - origin.left;
|
|
575
|
+
const y2 = b.top - origin.top;
|
|
576
|
+
const my = (y1 + y2) / 2;
|
|
577
|
+
return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}`;
|
|
578
|
+
}
|
|
579
|
+
const x1 = a.right - origin.left;
|
|
580
|
+
const y1 = a.top + a.height / 2 - origin.top;
|
|
581
|
+
const x2 = b.left - origin.left;
|
|
582
|
+
const y2 = b.top + b.height / 2 - origin.top;
|
|
583
|
+
const mx = (x1 + x2) / 2;
|
|
584
|
+
return `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
|
|
585
|
+
}
|
|
586
|
+
/** Pixels between parallel straight links. */
|
|
587
|
+
var LANE_GAP = 6;
|
|
588
|
+
/**
|
|
589
|
+
* Straight line between centres, shifted sideways by its lane; the layout draws nodes over the
|
|
590
|
+
* overlay to hide the ends. The shift is relative to a fixed orientation of the pair, so links in
|
|
591
|
+
* opposite directions between the same nodes still separate.
|
|
592
|
+
*/
|
|
593
|
+
function line(a, b, origin, lane) {
|
|
594
|
+
const x1 = a.left + a.width / 2 - origin.left;
|
|
595
|
+
const y1 = a.top + a.height / 2 - origin.top;
|
|
596
|
+
const x2 = b.left + b.width / 2 - origin.left;
|
|
597
|
+
const y2 = b.top + b.height / 2 - origin.top;
|
|
598
|
+
const flip = x1 < x2 || x1 === x2 && y1 < y2 ? 1 : -1;
|
|
599
|
+
const length = Math.hypot(x2 - x1, y2 - y1) || 1;
|
|
600
|
+
const dx = -(y2 - y1) / length * lane * LANE_GAP * flip;
|
|
601
|
+
const dy = (x2 - x1) / length * lane * LANE_GAP * flip;
|
|
602
|
+
return `M${x1 + dx},${y1 + dy} L${x2 + dx},${y2 + dy}`;
|
|
603
|
+
}
|
|
604
|
+
//#endregion
|
|
605
|
+
//#region src/layouts/TreeLayout.tsx
|
|
606
|
+
/**
|
|
607
|
+
* Tree over the schema's hierarchy. Flexbox does the branching; the SVG overlay draws connectors.
|
|
608
|
+
*/
|
|
609
|
+
function TreeLayout({ graph, region, instanceId, onNodeSelect }) {
|
|
610
|
+
const { orientation, dag } = region.layout;
|
|
611
|
+
const tree = useMemo(() => buildTree(graph, region.id, dag), [
|
|
612
|
+
graph,
|
|
613
|
+
region.id,
|
|
614
|
+
dag
|
|
615
|
+
]);
|
|
616
|
+
const links = useMemo(() => region.edges.show ? treeLinks(tree) : [], [tree, region.edges.show]);
|
|
617
|
+
const [container, setContainer] = useState(null);
|
|
618
|
+
const elements = useRef(/* @__PURE__ */ new Map()).current;
|
|
619
|
+
const register = useCallback((key) => (el) => {
|
|
620
|
+
if (el) elements.set(key, el);
|
|
621
|
+
else elements.delete(key);
|
|
622
|
+
}, [elements]);
|
|
623
|
+
const branch = (occurrence) => /* @__PURE__ */ jsxs("li", {
|
|
624
|
+
className: `mv:flex mv:items-center ${orientation === "vertical" ? "mv:flex-col" : "mv:flex-row"}`,
|
|
625
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
626
|
+
ref: register(occurrence.key),
|
|
627
|
+
"data-occurrence": `${instanceId}${occurrence.key}`,
|
|
628
|
+
children: /* @__PURE__ */ jsx(NodeCard, {
|
|
629
|
+
node: occurrence.node,
|
|
630
|
+
repeat: occurrence.repeatOf !== void 0,
|
|
631
|
+
nodes: region.nodes,
|
|
632
|
+
onSelect: onNodeSelect
|
|
633
|
+
})
|
|
634
|
+
}), occurrence.children.length > 0 && /* @__PURE__ */ jsx(Branches, {
|
|
635
|
+
orientation,
|
|
636
|
+
nested: true,
|
|
637
|
+
children: occurrence.children.map(branch)
|
|
638
|
+
})]
|
|
639
|
+
}, occurrence.key);
|
|
640
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
641
|
+
ref: setContainer,
|
|
642
|
+
className: "mv:relative mv:w-max mv:min-w-full mv:p-4",
|
|
643
|
+
children: [/* @__PURE__ */ jsx(Branches, {
|
|
644
|
+
orientation,
|
|
645
|
+
children: [...tree.roots, ...tree.orphans].map(branch)
|
|
646
|
+
}), /* @__PURE__ */ jsx(EdgeOverlay, {
|
|
647
|
+
links,
|
|
648
|
+
elements,
|
|
649
|
+
container,
|
|
650
|
+
routing: orientation
|
|
651
|
+
})]
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
function Branches({ orientation, nested = false, children }) {
|
|
655
|
+
return /* @__PURE__ */ jsx("ul", {
|
|
656
|
+
className: `mv:m-0 mv:flex mv:list-none mv:p-0 ${orientation === "vertical" ? `mv:flex-row mv:items-start mv:justify-center mv:gap-3 ${nested ? "mv:pt-10" : ""}` : `mv:flex-col mv:items-start mv:justify-center mv:gap-2 ${nested ? "mv:pl-14" : ""}`}`,
|
|
657
|
+
children
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
//#endregion
|
|
661
|
+
//#region src/core/xy.ts
|
|
662
|
+
/**
|
|
663
|
+
* Position nodes by two numeric fields. Every node of a plotted type appears once: on the plot
|
|
664
|
+
* when both its values are numbers inside the axes' domains, otherwise in `unplaced` with the
|
|
665
|
+
* reason. Positions are normalised; layouts decide the plot's size.
|
|
666
|
+
*/
|
|
667
|
+
function buildXYPlot(graph, regionId, layout) {
|
|
668
|
+
const { x, y } = layout;
|
|
669
|
+
const nodes = [...graph.nodes.values()];
|
|
670
|
+
const types = new Set(layout.types.length > 0 ? layout.types : nodes.filter((n) => isNumber(n.schemaData[x.field]) || isNumber(n.schemaData[y.field])).map((n) => n.resolvedType));
|
|
671
|
+
const plotted = nodes.filter((n) => types.has(n.resolvedType));
|
|
672
|
+
const domains = {
|
|
673
|
+
x: domainOf(x, plotted),
|
|
674
|
+
y: domainOf(y, plotted)
|
|
675
|
+
};
|
|
676
|
+
const points = [];
|
|
677
|
+
const unplaced = [];
|
|
678
|
+
for (const node of plotted) {
|
|
679
|
+
const key = occurrenceKey(regionId, [node.id]);
|
|
680
|
+
const px = position(node, x, domains.x);
|
|
681
|
+
const py = position(node, y, domains.y);
|
|
682
|
+
if (typeof px === "number" && typeof py === "number") points.push({
|
|
683
|
+
key,
|
|
684
|
+
node,
|
|
685
|
+
x: px,
|
|
686
|
+
y: py
|
|
687
|
+
});
|
|
688
|
+
else unplaced.push({
|
|
689
|
+
key,
|
|
690
|
+
node,
|
|
691
|
+
reasons: [px, py].filter((r) => typeof r === "string")
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
const keys = new Map(points.map((p) => [p.node.id, p.key]));
|
|
695
|
+
const fields = new Set(layout.edgeFields);
|
|
696
|
+
const links = edgesWithin(graph).filter((e) => keys.has(e.parent) && keys.has(e.child) && (fields.size === 0 || fields.has(e.field))).map((e) => ({
|
|
697
|
+
from: keys.get(e.parent),
|
|
698
|
+
to: keys.get(e.child),
|
|
699
|
+
field: e.field,
|
|
700
|
+
hierarchy: isHierarchyEdge(e, graph.hierarchy),
|
|
701
|
+
lane: 0
|
|
702
|
+
}));
|
|
703
|
+
assignLanes(links);
|
|
704
|
+
return {
|
|
705
|
+
points,
|
|
706
|
+
unplaced,
|
|
707
|
+
links,
|
|
708
|
+
domains
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
function isNumber(value) {
|
|
712
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
713
|
+
}
|
|
714
|
+
/** The axis's fixed domain, or the range of plotted values (widened when they're all equal). */
|
|
715
|
+
function domainOf(axis, nodes) {
|
|
716
|
+
if (Array.isArray(axis.domain)) return [axis.domain[0], axis.domain[1]];
|
|
717
|
+
const values = nodes.map((n) => n.schemaData[axis.field]).filter(isNumber);
|
|
718
|
+
if (values.length === 0) return [0, 1];
|
|
719
|
+
const min = Math.min(...values);
|
|
720
|
+
const max = Math.max(...values);
|
|
721
|
+
return min === max ? [min - 1, max + 1] : [min, max];
|
|
722
|
+
}
|
|
723
|
+
/** The node's normalised position on the axis, or why it has none. */
|
|
724
|
+
function position(node, axis, [min, max]) {
|
|
725
|
+
const value = node.schemaData[axis.field];
|
|
726
|
+
if (value === void 0 || value === null) return `no ${axis.field}`;
|
|
727
|
+
if (!isNumber(value)) return `${axis.field} is not a number`;
|
|
728
|
+
if (value < min || value > max) return `${axis.field} ${value} is outside ${min}–${max}`;
|
|
729
|
+
const t = (value - min) / (max - min);
|
|
730
|
+
return axis.direction === "descending" ? 1 - t : t;
|
|
731
|
+
}
|
|
732
|
+
/** Spread links joining the same pair of nodes (in either direction) into lanes around 0. */
|
|
733
|
+
function assignLanes(links) {
|
|
734
|
+
const byPair = /* @__PURE__ */ new Map();
|
|
735
|
+
for (const link of links) {
|
|
736
|
+
const pair = [link.from, link.to].sort().join("\n");
|
|
737
|
+
byPair.set(pair, [...byPair.get(pair) ?? [], link]);
|
|
738
|
+
}
|
|
739
|
+
for (const group of byPair.values()) group.forEach((link, i) => {
|
|
740
|
+
link.lane = i - (group.length - 1) / 2;
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
//#endregion
|
|
744
|
+
//#region src/layouts/XYLayout.tsx
|
|
745
|
+
/**
|
|
746
|
+
* Nodes positioned by two numeric fields, with straight connectors between them. Positions are
|
|
747
|
+
* percentages of the plot area, which fills the container (scrolling below a minimum size). Nodes that can't be
|
|
748
|
+
* placed are listed under the plot with the reason.
|
|
749
|
+
* PROTOTYPE: labels may overlap where nodes sit close together; no collision handling.
|
|
750
|
+
*/
|
|
751
|
+
function XYLayout({ graph, region, instanceId, onNodeSelect }) {
|
|
752
|
+
const { layout } = region;
|
|
753
|
+
const plot = useMemo(() => buildXYPlot(graph, region.id, layout), [
|
|
754
|
+
graph,
|
|
755
|
+
region.id,
|
|
756
|
+
layout
|
|
757
|
+
]);
|
|
758
|
+
const links = useMemo(() => region.edges.show ? plot.links.map(({ from, to, field, hierarchy, lane }) => ({
|
|
759
|
+
from,
|
|
760
|
+
to,
|
|
761
|
+
id: field,
|
|
762
|
+
lane,
|
|
763
|
+
kind: hierarchy ? "tree" : "cross"
|
|
764
|
+
})) : [], [plot, region.edges.show]);
|
|
765
|
+
const shape = region.nodes.style === "shape";
|
|
766
|
+
const [container, setContainer] = useState(null);
|
|
767
|
+
const elements = useRef(/* @__PURE__ */ new Map()).current;
|
|
768
|
+
const register = useCallback((key) => (el) => {
|
|
769
|
+
if (el) elements.set(key, el);
|
|
770
|
+
else elements.delete(key);
|
|
771
|
+
}, [elements]);
|
|
772
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
773
|
+
className: "mv:box-border mv:flex mv:h-full mv:min-h-[28rem] mv:min-w-[36rem] mv:flex-col mv:gap-4 mv:p-4",
|
|
774
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
775
|
+
className: "mv:grid mv:min-h-0 mv:flex-1 mv:grid-cols-[auto_1fr] mv:grid-rows-[1fr_auto] mv:gap-1",
|
|
776
|
+
children: [
|
|
777
|
+
/* @__PURE__ */ jsx(AxisTitle, {
|
|
778
|
+
axis: layout.y,
|
|
779
|
+
domain: plot.domains.y,
|
|
780
|
+
vertical: true
|
|
781
|
+
}),
|
|
782
|
+
/* @__PURE__ */ jsxs("div", {
|
|
783
|
+
ref: setContainer,
|
|
784
|
+
className: "mv:relative mv:border-b mv:border-l mv:border-edge",
|
|
785
|
+
children: [/* @__PURE__ */ jsx(EdgeOverlay, {
|
|
786
|
+
links,
|
|
787
|
+
elements,
|
|
788
|
+
container,
|
|
789
|
+
routing: "straight"
|
|
790
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
791
|
+
className: "mv:absolute mv:inset-8",
|
|
792
|
+
children: plot.points.map((point) => /* @__PURE__ */ jsxs("div", {
|
|
793
|
+
"data-occurrence": `${instanceId}${point.key}`,
|
|
794
|
+
className: `mv:absolute mv:flex mv:items-center mv:gap-1 ${shape ? "mv:-translate-x-3 mv:translate-y-3" : "mv:-translate-x-1/2 mv:translate-y-1/2"}`,
|
|
795
|
+
style: {
|
|
796
|
+
left: `${point.x * 100}%`,
|
|
797
|
+
bottom: `${point.y * 100}%`
|
|
798
|
+
},
|
|
799
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
800
|
+
ref: register(point.key),
|
|
801
|
+
children: /* @__PURE__ */ jsx(NodeCard, {
|
|
802
|
+
node: point.node,
|
|
803
|
+
repeat: false,
|
|
804
|
+
nodes: region.nodes,
|
|
805
|
+
onSelect: onNodeSelect
|
|
806
|
+
})
|
|
807
|
+
}), shape && /* @__PURE__ */ jsx("span", {
|
|
808
|
+
className: "mv:whitespace-nowrap mv:rounded mv:bg-surface/80 mv:px-0.5 mv:text-xs",
|
|
809
|
+
children: point.node.title
|
|
810
|
+
})]
|
|
811
|
+
}, point.key))
|
|
812
|
+
})]
|
|
813
|
+
}),
|
|
814
|
+
/* @__PURE__ */ jsx("div", {}),
|
|
815
|
+
/* @__PURE__ */ jsx(AxisTitle, {
|
|
816
|
+
axis: layout.x,
|
|
817
|
+
domain: plot.domains.x
|
|
818
|
+
})
|
|
819
|
+
]
|
|
820
|
+
}), plot.unplaced.length > 0 && /* @__PURE__ */ jsxs("section", {
|
|
821
|
+
className: "mv:flex mv:flex-col mv:gap-2",
|
|
822
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
823
|
+
className: "mv:text-xs mv:font-semibold mv:text-muted",
|
|
824
|
+
children: "Not plotted"
|
|
825
|
+
}), /* @__PURE__ */ jsx("ul", {
|
|
826
|
+
className: "mv:m-0 mv:flex mv:list-none mv:flex-wrap mv:gap-3 mv:p-0",
|
|
827
|
+
children: plot.unplaced.map(({ key, node, reasons }) => /* @__PURE__ */ jsxs("li", {
|
|
828
|
+
"data-occurrence": `${instanceId}${key}`,
|
|
829
|
+
className: "mv:flex mv:items-center mv:gap-1",
|
|
830
|
+
children: [/* @__PURE__ */ jsx(NodeCard, {
|
|
831
|
+
node,
|
|
832
|
+
repeat: false,
|
|
833
|
+
nodes: region.nodes,
|
|
834
|
+
onSelect: onNodeSelect
|
|
835
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
836
|
+
className: "mv:text-xs",
|
|
837
|
+
children: [shape && `${node.title} `, /* @__PURE__ */ jsxs("span", {
|
|
838
|
+
className: "mv:text-muted",
|
|
839
|
+
children: [
|
|
840
|
+
"(",
|
|
841
|
+
reasons.join("; "),
|
|
842
|
+
")"
|
|
843
|
+
]
|
|
844
|
+
})]
|
|
845
|
+
})]
|
|
846
|
+
}, key))
|
|
847
|
+
})]
|
|
848
|
+
})]
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
/** Axis title with the values at each end, in drawing order. */
|
|
852
|
+
function AxisTitle({ axis, domain: [min, max], vertical = false }) {
|
|
853
|
+
const [start, end] = axis.direction === "descending" ? [max, min] : [min, max];
|
|
854
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
855
|
+
className: `mv:flex mv:items-center mv:justify-between mv:gap-2 mv:text-xs mv:text-muted ${vertical ? "mv:flex-col-reverse mv:py-8" : "mv:px-8"}`,
|
|
856
|
+
children: [
|
|
857
|
+
/* @__PURE__ */ jsx("span", { children: format(start) }),
|
|
858
|
+
/* @__PURE__ */ jsx("span", {
|
|
859
|
+
className: `mv:font-semibold ${vertical ? "mv:[writing-mode:vertical-rl] mv:rotate-180" : ""}`,
|
|
860
|
+
children: axis.label ?? axis.field
|
|
861
|
+
}),
|
|
862
|
+
/* @__PURE__ */ jsx("span", { children: format(end) })
|
|
863
|
+
]
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
function format(value) {
|
|
867
|
+
return Number.isInteger(value) ? String(value) : value.toPrecision(3).replace(/\.?0+$/, "");
|
|
868
|
+
}
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/layouts/MapView.tsx
|
|
871
|
+
var EMPTY = /* @__PURE__ */ new Map();
|
|
872
|
+
/** Renders a map view definition against a space. Sizes to its container and scrolls within it. */
|
|
873
|
+
function MapView({ definition, graph, values, annotations, onNodeSelect }) {
|
|
874
|
+
const instanceId = useId();
|
|
875
|
+
const region = useMemo(() => resolveRegions(definition, values), [definition, values])[0];
|
|
876
|
+
const props = {
|
|
877
|
+
graph,
|
|
878
|
+
instanceId,
|
|
879
|
+
onNodeSelect
|
|
880
|
+
};
|
|
881
|
+
const { validation, validationSources } = region.nodes;
|
|
882
|
+
const visibleAnnotations = useMemo(() => annotations ? filterAnnotations(annotations, validation, validationSources) : EMPTY, [
|
|
883
|
+
annotations,
|
|
884
|
+
validation,
|
|
885
|
+
validationSources
|
|
886
|
+
]);
|
|
887
|
+
return /* @__PURE__ */ jsx(AnnotationsContext.Provider, {
|
|
888
|
+
value: visibleAnnotations,
|
|
889
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
890
|
+
className: "mv:h-full mv:w-full mv:overflow-auto mv:bg-surface mv:text-ink",
|
|
891
|
+
children: layoutFor(region, props)
|
|
892
|
+
})
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
function layoutFor(region, props) {
|
|
896
|
+
const { layout } = region;
|
|
897
|
+
switch (layout.type) {
|
|
898
|
+
case "tree": return /* @__PURE__ */ jsx(TreeLayout, {
|
|
899
|
+
...props,
|
|
900
|
+
region: {
|
|
901
|
+
...region,
|
|
902
|
+
layout
|
|
903
|
+
}
|
|
904
|
+
});
|
|
905
|
+
case "landscape": return /* @__PURE__ */ jsx(LandscapeLayout, {
|
|
906
|
+
...props,
|
|
907
|
+
region: {
|
|
908
|
+
...region,
|
|
909
|
+
layout
|
|
910
|
+
}
|
|
911
|
+
});
|
|
912
|
+
case "xy": return /* @__PURE__ */ jsx(XYLayout, {
|
|
913
|
+
...props,
|
|
914
|
+
region: {
|
|
915
|
+
...region,
|
|
916
|
+
layout
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/layouts/ParameterInputs.tsx
|
|
923
|
+
var same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
924
|
+
/** A checkbox for a boolean parameter, labelled with the parameter's title. */
|
|
925
|
+
function ToggleParameterInput({ parameter, value, onChange }) {
|
|
926
|
+
const id = useId();
|
|
927
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("input", {
|
|
928
|
+
id,
|
|
929
|
+
type: "checkbox",
|
|
930
|
+
checked: value === true,
|
|
931
|
+
onChange: (e) => onChange(e.target.checked)
|
|
932
|
+
}), /* @__PURE__ */ jsx("label", {
|
|
933
|
+
htmlFor: id,
|
|
934
|
+
children: parameter.title
|
|
935
|
+
})] });
|
|
936
|
+
}
|
|
937
|
+
/** A dropdown for a parameter with one of several options. */
|
|
938
|
+
function SelectParameterInput({ parameter, value, onChange }) {
|
|
939
|
+
const id = useId();
|
|
940
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("label", {
|
|
941
|
+
htmlFor: id,
|
|
942
|
+
className: "mv:text-muted",
|
|
943
|
+
children: parameter.title
|
|
944
|
+
}), /* @__PURE__ */ jsx("select", {
|
|
945
|
+
id,
|
|
946
|
+
value: JSON.stringify(value),
|
|
947
|
+
onChange: (e) => onChange(JSON.parse(e.target.value)),
|
|
948
|
+
children: parameter.control.options.map((option) => /* @__PURE__ */ jsx("option", {
|
|
949
|
+
value: JSON.stringify(option),
|
|
950
|
+
children: String(option)
|
|
951
|
+
}, JSON.stringify(option)))
|
|
952
|
+
})] });
|
|
953
|
+
}
|
|
954
|
+
/** A checkbox group for a parameter taking any subset of its options. */
|
|
955
|
+
function MultiSelectParameterInput({ parameter, value, onChange }) {
|
|
956
|
+
const { options } = parameter.control;
|
|
957
|
+
const selected = Array.isArray(value) ? value : [];
|
|
958
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
959
|
+
className: "mv:m-0 mv:flex mv:flex-wrap mv:items-center mv:gap-2 mv:border-0 mv:p-0",
|
|
960
|
+
children: [/* @__PURE__ */ jsx("legend", {
|
|
961
|
+
className: "mv:float-left mv:mr-1.5 mv:p-0 mv:text-muted",
|
|
962
|
+
children: parameter.title
|
|
963
|
+
}), options.map((option) => {
|
|
964
|
+
const checked = selected.some((v) => same(v, option));
|
|
965
|
+
return /* @__PURE__ */ jsxs("label", {
|
|
966
|
+
className: "mv:flex mv:items-center mv:gap-1",
|
|
967
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
968
|
+
type: "checkbox",
|
|
969
|
+
checked,
|
|
970
|
+
onChange: () => onChange(options.filter((o) => o === option ? !checked : selected.some((v) => same(v, o))))
|
|
971
|
+
}), String(option)]
|
|
972
|
+
}, JSON.stringify(option));
|
|
973
|
+
})]
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
/** The input matching a parameter's control kind. */
|
|
977
|
+
function ParameterInput({ parameter, value, onChange }) {
|
|
978
|
+
const { control } = parameter;
|
|
979
|
+
switch (control.kind) {
|
|
980
|
+
case "toggle": return /* @__PURE__ */ jsx(ToggleParameterInput, {
|
|
981
|
+
parameter: {
|
|
982
|
+
...parameter,
|
|
983
|
+
control
|
|
984
|
+
},
|
|
985
|
+
value,
|
|
986
|
+
onChange
|
|
987
|
+
});
|
|
988
|
+
case "select": return /* @__PURE__ */ jsx(SelectParameterInput, {
|
|
989
|
+
parameter: {
|
|
990
|
+
...parameter,
|
|
991
|
+
control
|
|
992
|
+
},
|
|
993
|
+
value,
|
|
994
|
+
onChange
|
|
995
|
+
});
|
|
996
|
+
case "multiselect": return /* @__PURE__ */ jsx(MultiSelectParameterInput, {
|
|
997
|
+
parameter: {
|
|
998
|
+
...parameter,
|
|
999
|
+
control
|
|
1000
|
+
},
|
|
1001
|
+
value,
|
|
1002
|
+
onChange
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
//#endregion
|
|
1007
|
+
//#region src/layouts/useParameterControls.ts
|
|
1008
|
+
/**
|
|
1009
|
+
* The state behind a map view's parameter controls, with no markup, for hosts that lay the controls
|
|
1010
|
+
* out themselves. Pair each entry with `ParameterInput`, or with one of the typed inputs.
|
|
1011
|
+
*/
|
|
1012
|
+
function useParameterControls(definition, values, onChange) {
|
|
1013
|
+
const resolved = useMemo(() => resolveParameterValues(definition, values), [definition, values]);
|
|
1014
|
+
return definition.parameters.map((parameter) => ({
|
|
1015
|
+
parameter,
|
|
1016
|
+
value: resolved[parameter.name],
|
|
1017
|
+
setValue: (value) => onChange(parameter.name, value)
|
|
1018
|
+
}));
|
|
1019
|
+
}
|
|
1020
|
+
//#endregion
|
|
1021
|
+
//#region src/layouts/ParameterControls.tsx
|
|
1022
|
+
/**
|
|
1023
|
+
* Default layout for a map view's parameter controls: a wrapping row. Hosts wanting their own layout
|
|
1024
|
+
* use `useParameterControls` with `ParameterInput` (or the typed inputs) instead.
|
|
1025
|
+
*/
|
|
1026
|
+
function ParameterControls({ definition, values, onChange }) {
|
|
1027
|
+
const controls = useParameterControls(definition, values, onChange);
|
|
1028
|
+
if (controls.length === 0) return null;
|
|
1029
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1030
|
+
className: "mv:flex mv:flex-wrap mv:items-center mv:gap-x-4 mv:gap-y-2 mv:text-sm",
|
|
1031
|
+
children: controls.map(({ parameter, value, setValue }) => /* @__PURE__ */ jsx("div", {
|
|
1032
|
+
className: "mv:flex mv:items-center mv:gap-1.5",
|
|
1033
|
+
title: parameter.description,
|
|
1034
|
+
children: /* @__PURE__ */ jsx(ParameterInput, {
|
|
1035
|
+
parameter,
|
|
1036
|
+
value,
|
|
1037
|
+
onChange: setValue
|
|
1038
|
+
})
|
|
1039
|
+
}, parameter.name))
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
//#endregion
|
|
1043
|
+
export { MAP_VIEW_SCHEMAS, MapView, MapViewDefinitionError, MultiSelectParameterInput, ParameterControls, ParameterInput, SelectParameterInput, ToggleParameterInput, annotationsFrom, mapViewsForSchema, parseMapViewDefinition, resolveParameterValues, selectionFilter, useParameterControls, viewValidationLevel, worstSeverity };
|
|
1044
|
+
|
|
1045
|
+
//# sourceMappingURL=index.js.map
|