structured-context-map-views 0.1.0 → 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/dist/{definition-KuPoA1mO.js → definition-Cft28YUA.js} +41 -7
- package/dist/definition-Cft28YUA.js.map +1 -0
- package/dist/index.js +385 -229
- package/dist/index.js.map +1 -1
- package/dist/node.js +2 -2
- package/dist/node.js.map +1 -1
- package/dist/styles.css +31 -0
- package/dist/types/core/annotations.d.ts +47 -0
- package/dist/types/core/definition.d.ts +6 -1
- package/dist/types/core/{schemas/definition.d.ts → format/definition-schema.d.ts} +18 -0
- package/dist/types/core/{schemas → format}/index.d.ts +33 -1
- package/dist/types/core/{schemas → format}/settings.d.ts +14 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/types/layouts/EdgeOverlay.d.ts +1 -1
- package/dist/types/layouts/LandscapeLayout.d.ts +1 -1
- package/dist/types/layouts/MapView.d.ts +8 -2
- package/dist/types/layouts/NodeCard.d.ts +1 -1
- package/dist/types/layouts/ParameterControls.d.ts +3 -3
- package/dist/types/layouts/ParameterInputs.d.ts +21 -0
- package/dist/types/layouts/TreeLayout.d.ts +1 -1
- package/dist/types/layouts/XYLayout.d.ts +1 -1
- package/dist/types/layouts/annotations-context.d.ts +4 -0
- package/dist/types/layouts/useParameterControls.d.ts +13 -0
- package/dist/types/node/index.d.ts +1 -1
- package/package.json +3 -3
- package/dist/definition-KuPoA1mO.js.map +0 -1
- package/dist/types/core/index.d.ts +0 -6
- /package/dist/types/core/{schemas → format}/landscape-layout.d.ts +0 -0
- /package/dist/types/core/{schemas → format}/param-ref.d.ts +0 -0
- /package/dist/types/core/{schemas → format}/schema-url.d.ts +0 -0
- /package/dist/types/core/{schemas → format}/tree-layout.d.ts +0 -0
- /package/dist/types/core/{schemas → format}/xy-layout.d.ts +0 -0
package/dist/index.js
CHANGED
|
@@ -1,6 +1,70 @@
|
|
|
1
|
-
import { a as resolveRegions, i as resolveParameterValues, n as mapViewsForSchema, o as selectionFilter, r as parseMapViewDefinition, s as
|
|
2
|
-
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
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
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
|
|
4
68
|
//#region src/core/graph.ts
|
|
5
69
|
var NONE = [];
|
|
6
70
|
/**
|
|
@@ -163,165 +227,6 @@ function hasValue(node, { field, value }) {
|
|
|
163
227
|
return valuesOf(node, field).includes(value);
|
|
164
228
|
}
|
|
165
229
|
//#endregion
|
|
166
|
-
//#region src/core/tree.ts
|
|
167
|
-
/**
|
|
168
|
-
* Lay a space out as a tree over its hierarchy.
|
|
169
|
-
*
|
|
170
|
-
* Roots are root-type nodes with no hierarchy parent. Traversal is depth-first in space order,
|
|
171
|
-
* so a node's primary occurrence is under the parent reached first. Under its other parents a
|
|
172
|
-
* node is either repeated without its subtree (`repeat`), or left out and linked by a cross-edge
|
|
173
|
-
* (`cross-edge`). Either way traversal stops there, which also keeps cycles from recursing.
|
|
174
|
-
*/
|
|
175
|
-
function buildTree(graph, regionId, dag = "repeat") {
|
|
176
|
-
const { hierarchy } = graph;
|
|
177
|
-
const levelTypes = new Set(hierarchy?.levels.map((level) => level.type));
|
|
178
|
-
const rootType = hierarchy?.levels[0]?.type;
|
|
179
|
-
const primary = /* @__PURE__ */ new Map();
|
|
180
|
-
const pendingCross = [];
|
|
181
|
-
const hierarchyParents = (title) => parentEdges(graph, title).filter((edge) => isHierarchyEdge(edge, hierarchy));
|
|
182
|
-
const visit = (node, path) => {
|
|
183
|
-
const nodePath = [...path, node.id];
|
|
184
|
-
const key = occurrenceKey(regionId, nodePath);
|
|
185
|
-
primary.set(node.id, key);
|
|
186
|
-
const childTitles = new Set(childEdges(graph, node.id).filter((edge) => isHierarchyEdge(edge, hierarchy)).map((edge) => edge.child));
|
|
187
|
-
const children = [];
|
|
188
|
-
for (const title of childTitles) {
|
|
189
|
-
const child = graph.nodes.get(title);
|
|
190
|
-
const repeatOf = primary.get(title);
|
|
191
|
-
if (repeatOf === void 0) children.push(visit(child, nodePath));
|
|
192
|
-
else if (dag === "repeat") children.push({
|
|
193
|
-
key: occurrenceKey(regionId, [...nodePath, title]),
|
|
194
|
-
node: child,
|
|
195
|
-
children: [],
|
|
196
|
-
repeatOf
|
|
197
|
-
});
|
|
198
|
-
else pendingCross.push({
|
|
199
|
-
from: key,
|
|
200
|
-
child: title
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
return {
|
|
204
|
-
key,
|
|
205
|
-
node,
|
|
206
|
-
children
|
|
207
|
-
};
|
|
208
|
-
};
|
|
209
|
-
const levelNodes = [...graph.nodes.values()].filter((node) => levelTypes.has(node.resolvedType));
|
|
210
|
-
const unparented = levelNodes.filter((node) => hierarchyParents(node.id).length === 0);
|
|
211
|
-
const roots = unparented.filter((node) => node.resolvedType === rootType).map((node) => visit(node, []));
|
|
212
|
-
const orphans = unparented.filter((node) => node.resolvedType !== rootType).map((node) => visit(node, []));
|
|
213
|
-
for (const node of levelNodes) if (!primary.has(node.id)) orphans.push(visit(node, []));
|
|
214
|
-
return {
|
|
215
|
-
roots,
|
|
216
|
-
orphans,
|
|
217
|
-
primary,
|
|
218
|
-
crossLinks: pendingCross.map(({ from, child }) => ({
|
|
219
|
-
from,
|
|
220
|
-
to: primary.get(child),
|
|
221
|
-
kind: "cross"
|
|
222
|
-
}))
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
/** Parent → child pairs of occurrence keys: one per rendered parent-child link, then cross-links. */
|
|
226
|
-
function treeLinks(tree) {
|
|
227
|
-
const links = [];
|
|
228
|
-
const walk = (occurrence) => {
|
|
229
|
-
for (const child of occurrence.children) {
|
|
230
|
-
links.push({
|
|
231
|
-
from: occurrence.key,
|
|
232
|
-
to: child.key,
|
|
233
|
-
kind: "tree"
|
|
234
|
-
});
|
|
235
|
-
walk(child);
|
|
236
|
-
}
|
|
237
|
-
};
|
|
238
|
-
for (const root of [...tree.roots, ...tree.orphans]) walk(root);
|
|
239
|
-
return [...links, ...tree.crossLinks];
|
|
240
|
-
}
|
|
241
|
-
//#endregion
|
|
242
|
-
//#region src/core/xy.ts
|
|
243
|
-
/**
|
|
244
|
-
* Position nodes by two numeric fields. Every node of a plotted type appears once: on the plot
|
|
245
|
-
* when both its values are numbers inside the axes' domains, otherwise in `unplaced` with the
|
|
246
|
-
* reason. Positions are normalised; layouts decide the plot's size.
|
|
247
|
-
*/
|
|
248
|
-
function buildXYPlot(graph, regionId, layout) {
|
|
249
|
-
const { x, y } = layout;
|
|
250
|
-
const nodes = [...graph.nodes.values()];
|
|
251
|
-
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));
|
|
252
|
-
const plotted = nodes.filter((n) => types.has(n.resolvedType));
|
|
253
|
-
const domains = {
|
|
254
|
-
x: domainOf(x, plotted),
|
|
255
|
-
y: domainOf(y, plotted)
|
|
256
|
-
};
|
|
257
|
-
const points = [];
|
|
258
|
-
const unplaced = [];
|
|
259
|
-
for (const node of plotted) {
|
|
260
|
-
const key = occurrenceKey(regionId, [node.id]);
|
|
261
|
-
const px = position(node, x, domains.x);
|
|
262
|
-
const py = position(node, y, domains.y);
|
|
263
|
-
if (typeof px === "number" && typeof py === "number") points.push({
|
|
264
|
-
key,
|
|
265
|
-
node,
|
|
266
|
-
x: px,
|
|
267
|
-
y: py
|
|
268
|
-
});
|
|
269
|
-
else unplaced.push({
|
|
270
|
-
key,
|
|
271
|
-
node,
|
|
272
|
-
reasons: [px, py].filter((r) => typeof r === "string")
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
const keys = new Map(points.map((p) => [p.node.id, p.key]));
|
|
276
|
-
const fields = new Set(layout.edgeFields);
|
|
277
|
-
const links = edgesWithin(graph).filter((e) => keys.has(e.parent) && keys.has(e.child) && (fields.size === 0 || fields.has(e.field))).map((e) => ({
|
|
278
|
-
from: keys.get(e.parent),
|
|
279
|
-
to: keys.get(e.child),
|
|
280
|
-
field: e.field,
|
|
281
|
-
hierarchy: isHierarchyEdge(e, graph.hierarchy),
|
|
282
|
-
lane: 0
|
|
283
|
-
}));
|
|
284
|
-
assignLanes(links);
|
|
285
|
-
return {
|
|
286
|
-
points,
|
|
287
|
-
unplaced,
|
|
288
|
-
links,
|
|
289
|
-
domains
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
function isNumber(value) {
|
|
293
|
-
return typeof value === "number" && Number.isFinite(value);
|
|
294
|
-
}
|
|
295
|
-
/** The axis's fixed domain, or the range of plotted values (widened when they're all equal). */
|
|
296
|
-
function domainOf(axis, nodes) {
|
|
297
|
-
if (Array.isArray(axis.domain)) return [axis.domain[0], axis.domain[1]];
|
|
298
|
-
const values = nodes.map((n) => n.schemaData[axis.field]).filter(isNumber);
|
|
299
|
-
if (values.length === 0) return [0, 1];
|
|
300
|
-
const min = Math.min(...values);
|
|
301
|
-
const max = Math.max(...values);
|
|
302
|
-
return min === max ? [min - 1, max + 1] : [min, max];
|
|
303
|
-
}
|
|
304
|
-
/** The node's normalised position on the axis, or why it has none. */
|
|
305
|
-
function position(node, axis, [min, max]) {
|
|
306
|
-
const value = node.schemaData[axis.field];
|
|
307
|
-
if (value === void 0 || value === null) return `no ${axis.field}`;
|
|
308
|
-
if (!isNumber(value)) return `${axis.field} is not a number`;
|
|
309
|
-
if (value < min || value > max) return `${axis.field} ${value} is outside ${min}–${max}`;
|
|
310
|
-
const t = (value - min) / (max - min);
|
|
311
|
-
return axis.direction === "descending" ? 1 - t : t;
|
|
312
|
-
}
|
|
313
|
-
/** Spread links joining the same pair of nodes (in either direction) into lanes around 0. */
|
|
314
|
-
function assignLanes(links) {
|
|
315
|
-
const byPair = /* @__PURE__ */ new Map();
|
|
316
|
-
for (const link of links) {
|
|
317
|
-
const pair = [link.from, link.to].sort().join("\n");
|
|
318
|
-
byPair.set(pair, [...byPair.get(pair) ?? [], link]);
|
|
319
|
-
}
|
|
320
|
-
for (const group of byPair.values()) group.forEach((link, i) => {
|
|
321
|
-
link.lane = i - (group.length - 1) / 2;
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
//#endregion
|
|
325
230
|
//#region src/layouts/NodeCard.tsx
|
|
326
231
|
/**
|
|
327
232
|
* A clickable node, drawn as a card (title plus selected fields) or a compact shape with a
|
|
@@ -330,7 +235,8 @@ function assignLanes(links) {
|
|
|
330
235
|
*/
|
|
331
236
|
function NodeCard({ node, repeat, nodes, onSelect }) {
|
|
332
237
|
const select = () => onSelect?.(node);
|
|
333
|
-
const
|
|
238
|
+
const annotations = useAnnotations().get(node.id);
|
|
239
|
+
const label = `${node.resolvedType}: ${node.title}${repeat ? " (shown in full elsewhere)" : ""}${annotationsLabel(annotations)}`;
|
|
334
240
|
const onKeyDown = (e) => {
|
|
335
241
|
if (e.key === "Enter" || e.key === " ") {
|
|
336
242
|
e.preventDefault();
|
|
@@ -338,18 +244,18 @@ function NodeCard({ node, repeat, nodes, onSelect }) {
|
|
|
338
244
|
}
|
|
339
245
|
};
|
|
340
246
|
const border = repeat ? "mv:border-dashed mv:border-muted/60 mv:opacity-75" : "mv:border-edge";
|
|
341
|
-
if (nodes.style === "shape") return /* @__PURE__ */
|
|
247
|
+
if (nodes.style === "shape") return /* @__PURE__ */ jsxs("div", {
|
|
342
248
|
role: "button",
|
|
343
249
|
tabIndex: 0,
|
|
344
250
|
title: label,
|
|
345
251
|
onClick: select,
|
|
346
252
|
onKeyDown,
|
|
347
253
|
"aria-label": label,
|
|
348
|
-
className: `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}`,
|
|
349
|
-
children: /* @__PURE__ */ jsx("span", {
|
|
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", {
|
|
350
256
|
"aria-hidden": "true",
|
|
351
257
|
children: node.resolvedType.charAt(0)
|
|
352
|
-
})
|
|
258
|
+
}), /* @__PURE__ */ jsx(AnnotationBadge, { annotations })]
|
|
353
259
|
});
|
|
354
260
|
const fields = nodes.fields.map((field) => [field, formatValue(node.schemaData[field])]).filter(([, value]) => value !== void 0);
|
|
355
261
|
return /* @__PURE__ */ jsxs("div", {
|
|
@@ -358,7 +264,7 @@ function NodeCard({ node, repeat, nodes, onSelect }) {
|
|
|
358
264
|
title: label,
|
|
359
265
|
onClick: select,
|
|
360
266
|
onKeyDown,
|
|
361
|
-
className: `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}`,
|
|
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}`,
|
|
362
268
|
children: [
|
|
363
269
|
/* @__PURE__ */ jsxs("span", {
|
|
364
270
|
className: "mv:block mv:text-[0.65rem] mv:uppercase mv:tracking-wide mv:text-muted",
|
|
@@ -380,10 +286,31 @@ function NodeCard({ node, repeat, nodes, onSelect }) {
|
|
|
380
286
|
children: value
|
|
381
287
|
})]
|
|
382
288
|
}, field))
|
|
383
|
-
})
|
|
289
|
+
}),
|
|
290
|
+
/* @__PURE__ */ jsx(AnnotationBadge, { annotations })
|
|
384
291
|
]
|
|
385
292
|
});
|
|
386
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
|
+
}
|
|
387
314
|
function formatValue(value) {
|
|
388
315
|
if (value === void 0 || value === null || value === "") return void 0;
|
|
389
316
|
if (Array.isArray(value)) return value.map(String).join(", ");
|
|
@@ -513,6 +440,82 @@ function displayValue(value) {
|
|
|
513
440
|
return value.replace(/^\[\[([^\]|]+)(\|[^\]]*)?\]\]$/, "$1");
|
|
514
441
|
}
|
|
515
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
|
|
516
519
|
//#region src/layouts/EdgeOverlay.tsx
|
|
517
520
|
/**
|
|
518
521
|
* SVG layer drawing connectors between rendered occurrences.
|
|
@@ -655,6 +658,89 @@ function Branches({ orientation, nested = false, children }) {
|
|
|
655
658
|
});
|
|
656
659
|
}
|
|
657
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
|
|
658
744
|
//#region src/layouts/XYLayout.tsx
|
|
659
745
|
/**
|
|
660
746
|
* Nodes positioned by two numeric fields, with straight connectors between them. Positions are
|
|
@@ -782,16 +868,27 @@ function format(value) {
|
|
|
782
868
|
}
|
|
783
869
|
//#endregion
|
|
784
870
|
//#region src/layouts/MapView.tsx
|
|
871
|
+
var EMPTY = /* @__PURE__ */ new Map();
|
|
785
872
|
/** Renders a map view definition against a space. Sizes to its container and scrolls within it. */
|
|
786
|
-
function MapView({ definition, graph, values, onNodeSelect }) {
|
|
873
|
+
function MapView({ definition, graph, values, annotations, onNodeSelect }) {
|
|
787
874
|
const instanceId = useId();
|
|
788
875
|
const region = useMemo(() => resolveRegions(definition, values), [definition, values])[0];
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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)
|
|
795
892
|
})
|
|
796
893
|
});
|
|
797
894
|
}
|
|
@@ -822,68 +919,127 @@ function layoutFor(region, props) {
|
|
|
822
919
|
}
|
|
823
920
|
}
|
|
824
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
|
|
825
1021
|
//#region src/layouts/ParameterControls.tsx
|
|
826
1022
|
/**
|
|
827
|
-
*
|
|
828
|
-
*
|
|
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.
|
|
829
1025
|
*/
|
|
830
1026
|
function ParameterControls({ definition, values, onChange }) {
|
|
831
|
-
const
|
|
832
|
-
|
|
833
|
-
const { parameters } = definition;
|
|
834
|
-
if (parameters.length === 0) return null;
|
|
1027
|
+
const controls = useParameterControls(definition, values, onChange);
|
|
1028
|
+
if (controls.length === 0) return null;
|
|
835
1029
|
return /* @__PURE__ */ jsx("div", {
|
|
836
1030
|
className: "mv:flex mv:flex-wrap mv:items-center mv:gap-x-4 mv:gap-y-2 mv:text-sm",
|
|
837
|
-
children:
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
type: "checkbox",
|
|
847
|
-
checked: value === true,
|
|
848
|
-
onChange: (e) => onChange(param.name, e.target.checked)
|
|
849
|
-
}), /* @__PURE__ */ jsx("label", {
|
|
850
|
-
htmlFor: controlId,
|
|
851
|
-
children: param.title
|
|
852
|
-
})] }) : control.kind === "select" ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("label", {
|
|
853
|
-
htmlFor: controlId,
|
|
854
|
-
className: "mv:text-muted",
|
|
855
|
-
children: param.title
|
|
856
|
-
}), /* @__PURE__ */ jsx("select", {
|
|
857
|
-
id: controlId,
|
|
858
|
-
value: JSON.stringify(value),
|
|
859
|
-
onChange: (e) => onChange(param.name, JSON.parse(e.target.value)),
|
|
860
|
-
children: control.options.map((option) => /* @__PURE__ */ jsx("option", {
|
|
861
|
-
value: JSON.stringify(option),
|
|
862
|
-
children: String(option)
|
|
863
|
-
}, JSON.stringify(option)))
|
|
864
|
-
})] }) : /* @__PURE__ */ jsxs("fieldset", {
|
|
865
|
-
className: "mv:m-0 mv:flex mv:flex-wrap mv:items-center mv:gap-2 mv:border-0 mv:p-0",
|
|
866
|
-
children: [/* @__PURE__ */ jsx("legend", {
|
|
867
|
-
className: "mv:float-left mv:mr-1.5 mv:p-0 mv:text-muted",
|
|
868
|
-
children: param.title
|
|
869
|
-
}), control.options.map((option) => {
|
|
870
|
-
const selected = Array.isArray(value) ? value : [];
|
|
871
|
-
const checked = selected.some((v) => JSON.stringify(v) === JSON.stringify(option));
|
|
872
|
-
return /* @__PURE__ */ jsxs("label", {
|
|
873
|
-
className: "mv:flex mv:items-center mv:gap-1",
|
|
874
|
-
children: [/* @__PURE__ */ jsx("input", {
|
|
875
|
-
type: "checkbox",
|
|
876
|
-
checked,
|
|
877
|
-
onChange: () => onChange(param.name, control.options.filter((o) => o === option ? !checked : selected.some((v) => JSON.stringify(v) === JSON.stringify(o))))
|
|
878
|
-
}), String(option)]
|
|
879
|
-
}, JSON.stringify(option));
|
|
880
|
-
})]
|
|
881
|
-
})
|
|
882
|
-
}, param.name);
|
|
883
|
-
})
|
|
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))
|
|
884
1040
|
});
|
|
885
1041
|
}
|
|
886
1042
|
//#endregion
|
|
887
|
-
export { MAP_VIEW_SCHEMAS, MapView, MapViewDefinitionError, ParameterControls, mapViewsForSchema, parseMapViewDefinition, resolveParameterValues, selectionFilter };
|
|
1043
|
+
export { MAP_VIEW_SCHEMAS, MapView, MapViewDefinitionError, MultiSelectParameterInput, ParameterControls, ParameterInput, SelectParameterInput, ToggleParameterInput, annotationsFrom, mapViewsForSchema, parseMapViewDefinition, resolveParameterValues, selectionFilter, useParameterControls, viewValidationLevel, worstSeverity };
|
|
888
1044
|
|
|
889
1045
|
//# sourceMappingURL=index.js.map
|