@liquidcars/atlas 0.1.48 → 0.1.49
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/README.md +50 -0
- package/dist/liquidcars-atlas.js +2352 -785
- package/dist/liquidcars-atlas.js.map +1 -1
- package/package.json +1 -1
- package/src/diagram-svg.js +603 -0
- package/src/diagram-view.js +502 -0
- package/src/document-navigation.js +27 -0
- package/src/liquidcars-atlas.js +547 -118
- package/src/panel-content.js +84 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/** The document's 2D presentation. The SVG is also the source of SVG/PNG exports. */
|
|
2
|
+
const escapeHtml = value => String(value ?? "").replace(/[&<>"']/g, char => ({
|
|
3
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
|
4
|
+
})[char]);
|
|
5
|
+
|
|
6
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
7
|
+
|
|
8
|
+
export const DIAGRAM_VIEW_CSS = `
|
|
9
|
+
.diagram-view{position:absolute;inset:0;overflow:auto;background-color:#f5f5f5;background-image:radial-gradient(circle,#b3b3b3 .8px,transparent 1px);background-size:16px 16px;color:#172c3a;cursor:grab;touch-action:none}
|
|
10
|
+
.diagram-view[hidden],:host(:not([data-view-mode="2d"])) .diagram-view{display:none!important}
|
|
11
|
+
:host([data-view-mode="2d"]) .viewport>canvas,:host([data-view-mode="2d"]) .atlas-jump{visibility:hidden;pointer-events:none}
|
|
12
|
+
:host([data-view-mode="2d"]) .toolbar-group[data-toolbar-group="camera"],
|
|
13
|
+
:host([data-view-mode="2d"]) .toolbar-group[data-toolbar-group="copy"]{display:none}
|
|
14
|
+
:host(:not([data-view-mode="2d"])) .toolbar-group[data-toolbar-group="diagram"]{display:none}
|
|
15
|
+
:host(:not([data-view-mode="2d"])) [data-action="diagram-relations-within"]{display:none!important}
|
|
16
|
+
:host([data-view-mode="2d"]) .toolbar{width:max-content;max-width:calc(100% - 16px);flex-wrap:wrap;justify-content:center}
|
|
17
|
+
:host([data-view-mode="2d"]) .toolbar-group[data-toolbar-group="diagram"]{flex-wrap:wrap;justify-content:center}
|
|
18
|
+
.diagram-content{position:relative;width:max-content;min-width:100%;min-height:100%;display:grid;place-items:center;padding:12px}
|
|
19
|
+
.diagram-content svg{display:block;max-width:none;overflow:visible}
|
|
20
|
+
.diagram-content [data-entity],.diagram-content [data-relation]{cursor:pointer}
|
|
21
|
+
.diagram-content [data-entity]:hover,.diagram-content [data-relation]:hover{filter:drop-shadow(0 0 5px #23a58f)}
|
|
22
|
+
.diagram-content [data-entity].selected,.diagram-content [data-relation].selected{filter:drop-shadow(0 0 7px #008fcb)}
|
|
23
|
+
.diagram-view.dragging{cursor:grabbing}
|
|
24
|
+
.diagram-toolbar select,.diagram-toolbar input{accent-color:#6ad7c0}
|
|
25
|
+
.diagram-toolbar .toolbar-popover{max-height:min(430px,65vh);overflow:auto}
|
|
26
|
+
.diagram-toolbar .toolbar-option.checkbox{display:flex;gap:8px;align-items:center}
|
|
27
|
+
.diagram-toolbar .toolbar-option.checkbox input{pointer-events:none}
|
|
28
|
+
.diagram-toolbar .diagram-spacing{padding:7px 9px;color:#dbe7ec;font-size:12px}
|
|
29
|
+
.diagram-toolbar .diagram-spacing input{width:100%;display:block;margin-top:6px}
|
|
30
|
+
.diagram-toolbar .diagram-items{max-height:220px;overflow:auto}
|
|
31
|
+
.diagram-orientation-cube{position:absolute;z-index:9;top:12px;left:12px;width:110px;height:110px;touch-action:none;cursor:grab}
|
|
32
|
+
.diagram-orientation-cube[hidden],:host(:not([data-view-mode="2d"])) .diagram-orientation-cube{display:none!important}
|
|
33
|
+
.diagram-orientation-cube svg{width:100%;height:100%}
|
|
34
|
+
.diagram-orientation-cube polygon{fill:#476782;stroke:#a8d0df;stroke-width:1.4}
|
|
35
|
+
.diagram-orientation-cube polygon:hover{fill:#4caeaa}
|
|
36
|
+
.diagram-orientation-cube text{fill:#fff;font:bold 10px system-ui;pointer-events:none}
|
|
37
|
+
.diagram-orientation-cube circle{fill:#132b3b;stroke:#50758b}
|
|
38
|
+
.diagram-toolbar .diagram-orbit,.diagram-toolbar .diagram-camera-zoom{display:block;padding:6px 9px;color:#dbe7ec;font-size:12px}
|
|
39
|
+
.diagram-toolbar .diagram-orbit select,.diagram-toolbar .diagram-camera-zoom input{display:block;width:100%;margin-top:4px}
|
|
40
|
+
:host([data-view-mode="2d"]) .hint{color:#527286}
|
|
41
|
+
@media print{:host([data-view-mode="2d"]) .diagram-view{display:block!important;overflow:hidden}.diagram-orientation-cube{display:none!important}}
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
const menu = (name, label, content) => `<details class="toolbar-menu" data-toolbar-menu="diagram-${name}"><summary aria-label="${label}" title="${label}"><span class="toolbar-menu-status">${label}</span><svg class="toolbar-menu-chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="m8 10 4 4 4-4"/></svg></summary><div class="toolbar-popover" role="menu"><div class="toolbar-popover-title">${label}</div>${content}</div></details>`;
|
|
45
|
+
const option = (action, label) => `<button type="button" class="toolbar-option" data-action="${action}"><span>${label}</span></button>`;
|
|
46
|
+
|
|
47
|
+
export const DIAGRAM_TOOLBAR = `<div class="toolbar-group diagram-toolbar" data-toolbar-group="diagram">
|
|
48
|
+
<button class="toolbar-icon" data-action="diagram-minus" aria-label="Zoom out" title="Zoom out">−</button>
|
|
49
|
+
<button class="toolbar-icon" data-action="diagram-plus" aria-label="Zoom in" title="Zoom in">+</button>
|
|
50
|
+
<button class="toolbar-icon" data-action="diagram-fit" aria-label="Fit diagram" title="Fit diagram">⛶</button>
|
|
51
|
+
${menu("orientation", "Orientation", '<div class="diagram-orientation-cube" data-diagram-cube tabindex="0" role="group" aria-label="Drag to orbit or click a face"></div><button type="button" class="toolbar-option checkbox" data-action="diagram-cube"><input type="checkbox" data-diagram-cube-visible><span>Show cube</span></button><label class="diagram-orbit">Orbit around<select data-diagram-orbit><option value="">Model center</option></select></label><label class="diagram-camera-zoom">Camera zoom<input type="range" min="50" max="500" step="10" data-diagram-camera-zoom></label>' + ["front", "back", "left", "right", "top", "bottom", "oblique"].map(value => option(`diagram-${value}`, value[0].toUpperCase() + value.slice(1))).join("") + option("diagram-turn-left", "Turn left 15°") + option("diagram-turn-right", "Turn right 15°"))}
|
|
52
|
+
${menu("items", "Items", '<div class="diagram-items" data-diagram-items></div>')}
|
|
53
|
+
${menu("options", "Options", '<label class="diagram-spacing">Spacing<input type="range" min="0" max="150" data-diagram-gap></label>')}
|
|
54
|
+
</div>`;
|
|
55
|
+
|
|
56
|
+
const DEFAULT_VIEW = Object.freeze({
|
|
57
|
+
cameraView: { position: [0, 0, 30], target: [0, 0, 0] },
|
|
58
|
+
cameraZoom: 1, gap: 20, labels: true, relations: "all", showOrientationCube: true,
|
|
59
|
+
focus: { mode: "all", effect: "dim", layout: "preserve", opacity: .2 }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export function normalizedDiagramView(value = {}) {
|
|
63
|
+
const view = value && typeof value === "object" ? value : {};
|
|
64
|
+
return {
|
|
65
|
+
...DEFAULT_VIEW, ...view,
|
|
66
|
+
cameraView: view.cameraView?.position?.length === 3 && view.cameraView?.target?.length === 3
|
|
67
|
+
? { position: [...view.cameraView.position], target: [...view.cameraView.target] }
|
|
68
|
+
: { position: [...DEFAULT_VIEW.cameraView.position], target: [...DEFAULT_VIEW.cameraView.target] },
|
|
69
|
+
cameraZoom: clamp(Number(view.cameraZoom) || 1, .5, 5),
|
|
70
|
+
gap: clamp(Number(view.gap ?? 20) || 0, 0, 150),
|
|
71
|
+
labels: view.labels !== false,
|
|
72
|
+
showOrientationCube: view.showOrientationCube !== false,
|
|
73
|
+
relations: ["all", "within", "selected", "callers", "called", "none"].includes(view.relations) ? view.relations : "all",
|
|
74
|
+
focus: {
|
|
75
|
+
mode: view.focus?.mode === "connected" ? "connected" : "all",
|
|
76
|
+
effect: view.focus?.effect === "hide" ? "hide" : "dim",
|
|
77
|
+
layout: view.focus?.layout === "reflow" ? "reflow" : "preserve",
|
|
78
|
+
opacity: clamp(Number(view.focus?.opacity ?? .2), 0, 1)
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function diagramItemNode(atlas, node) {
|
|
84
|
+
return node.type !== "container" && node.type !== "shell" || (atlas.open && !atlas.open.has(node.id));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function diagramNodeVisible(atlas, node) {
|
|
88
|
+
return typeof atlas._isVisible === "function" ? atlas._isVisible(node) : node.group?.visible;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function modelCenter(atlas) {
|
|
92
|
+
const points = [...atlas.nodes.values()].filter(node => diagramNodeVisible(atlas, node) && diagramItemNode(atlas, node)).map(node => node.group.position);
|
|
93
|
+
if (!points.length) return [0, 0, 0];
|
|
94
|
+
return ["x", "y", "z"].map(axis => (Math.min(...points.map(point => point[axis])) + Math.max(...points.map(point => point[axis]))) / 2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function cameraView(target, yaw, pitch) {
|
|
98
|
+
const distance = 30;
|
|
99
|
+
return { target: [...target], position: [target[0] + distance * Math.sin(yaw) * Math.cos(pitch), target[1] + distance * Math.sin(pitch), target[2] + distance * Math.cos(yaw) * Math.cos(pitch)] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function cameraAngles(view) {
|
|
103
|
+
const [x, y, z] = view.position.map((value, index) => value - view.target[index]);
|
|
104
|
+
return { yaw: Math.atan2(x, z), pitch: Math.asin(clamp(y / (Math.hypot(x, y, z) || 1), -1, 1)) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const CUBE_FACES = [
|
|
108
|
+
{ id: "front", normal: [0, 0, 1], corners: [[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]] },
|
|
109
|
+
{ id: "back", normal: [0, 0, -1], corners: [[1,-1,-1],[-1,-1,-1],[-1,1,-1],[1,1,-1]] },
|
|
110
|
+
{ id: "right", normal: [1, 0, 0], corners: [[1,-1,1],[1,-1,-1],[1,1,-1],[1,1,1]] },
|
|
111
|
+
{ id: "left", normal: [-1, 0, 0], corners: [[-1,-1,-1],[-1,-1,1],[-1,1,1],[-1,1,-1]] },
|
|
112
|
+
{ id: "top", normal: [0, 1, 0], corners: [[-1,1,1],[1,1,1],[1,1,-1],[-1,1,-1]] },
|
|
113
|
+
{ id: "bottom", normal: [0, -1, 0], corners: [[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]] }
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function orientationCubeSvg({ yaw, pitch }) {
|
|
117
|
+
const dot = (a, b) => a.reduce((sum, value, index) => sum + value * b[index], 0);
|
|
118
|
+
const direction = [Math.sin(yaw) * Math.cos(pitch), Math.sin(pitch), Math.cos(yaw) * Math.cos(pitch)];
|
|
119
|
+
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
|
120
|
+
const up = [-Math.sin(yaw) * Math.sin(pitch), Math.cos(pitch), -Math.cos(yaw) * Math.sin(pitch)];
|
|
121
|
+
const project = point => [70 + 38 * dot(right, point), 70 - 38 * dot(up, point)];
|
|
122
|
+
const faces = CUBE_FACES.filter(face => dot(face.normal, direction) > .01)
|
|
123
|
+
.sort((a, b) => dot(a.normal, direction) - dot(b.normal, direction));
|
|
124
|
+
const shapes = faces.map(face => {
|
|
125
|
+
const points = face.corners.map(point => project(point).map(value => value.toFixed(1)).join(",")).join(" ");
|
|
126
|
+
const [x, y] = project(face.normal);
|
|
127
|
+
return '<g data-cube-face="' + face.id + '"><polygon points="' + points
|
|
128
|
+
+ '"/><text x="' + x.toFixed(1) + '" y="' + (y + 3).toFixed(1)
|
|
129
|
+
+ '" text-anchor="middle">' + face.id.toUpperCase() + "</text></g>";
|
|
130
|
+
}).join("");
|
|
131
|
+
return '<svg viewBox="0 0 140 140" role="img" aria-label="Model orientation"><circle cx="70" cy="70" r="62"/>' + shapes + "</svg>";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function panTarget(target, { yaw, pitch }, dx, dy, pixelScale) {
|
|
135
|
+
const right = [Math.cos(yaw), 0, -Math.sin(yaw)];
|
|
136
|
+
const up = [-Math.sin(yaw) * Math.sin(pitch), Math.cos(pitch), -Math.cos(yaw) * Math.sin(pitch)];
|
|
137
|
+
return target.map((value, index) => value - right[index] * dx / pixelScale + up[index] * dy / pixelScale);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export class DiagramView {
|
|
141
|
+
constructor(atlas) {
|
|
142
|
+
this.atlas = atlas;
|
|
143
|
+
this.root = atlas.shadowRoot.querySelector(".diagram-view");
|
|
144
|
+
this.content = this.root.querySelector(".diagram-content");
|
|
145
|
+
this.cube = atlas.toolbar.querySelector("[data-diagram-cube]");
|
|
146
|
+
atlas.shadowRoot.querySelector(".viewport").append(this.cube);
|
|
147
|
+
atlas.toolbar.querySelector('[data-toolbar-menu="relations"] .toolbar-popover')
|
|
148
|
+
?.insertAdjacentHTML("beforeend", option("diagram-relations-within", "Same container"));
|
|
149
|
+
this.config = normalizedDiagramView();
|
|
150
|
+
this.zoom = "fit";
|
|
151
|
+
this.result = null;
|
|
152
|
+
this._bind();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get state() { return structuredClone(this.config); }
|
|
156
|
+
|
|
157
|
+
setState(value = {}) {
|
|
158
|
+
this.config = normalizedDiagramView(value?.cameraView ? value : {
|
|
159
|
+
...value, cameraView: cameraView(modelCenter(this.atlas), 0, 0)
|
|
160
|
+
});
|
|
161
|
+
this.zoom = "fit";
|
|
162
|
+
this.refresh();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
captureState() {
|
|
166
|
+
const state = this.state;
|
|
167
|
+
// Layout and framing are derived from the model and the current canvas.
|
|
168
|
+
// Older documents may have captured every computed position; omit those
|
|
169
|
+
// snapshots from new markup while continuing to read them on import.
|
|
170
|
+
delete state.placements;
|
|
171
|
+
delete state.frameAspect;
|
|
172
|
+
return state;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
setConfig(patch, { layout = false, defer = false } = {}) {
|
|
176
|
+
this.config = normalizedDiagramView({ ...this.config, ...patch, ...(layout ? { placements: undefined, frameAspect: undefined } : {}) });
|
|
177
|
+
if (defer && globalThis.requestAnimationFrame) this.scheduleRefresh();
|
|
178
|
+
else this.refresh();
|
|
179
|
+
this.atlas.dispatchEvent(new CustomEvent("lc-diagram-change", { detail: { state: this.state }, bubbles: true, composed: true }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
scheduleRefresh() {
|
|
183
|
+
if (this._refreshQueued) return;
|
|
184
|
+
this._refreshQueued = true;
|
|
185
|
+
requestAnimationFrame(() => { if (this._refreshQueued) this.refresh(); });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
renderOptions(extra = {}) {
|
|
189
|
+
const state = this.config;
|
|
190
|
+
const focused = this._focusedIds();
|
|
191
|
+
return {
|
|
192
|
+
cameraView: state.cameraView, cameraFrame: { zoom: state.cameraZoom, aspect: state.frameAspect || this.root.clientWidth / Math.max(1, this.root.clientHeight) },
|
|
193
|
+
includeIds: state.includeIds, relationGroups: state.relationGroups,
|
|
194
|
+
relations: state.relations, relationIndexes: this._relationIndexes(),
|
|
195
|
+
focus: focused ? { ids: [...focused], ...state.focus } : null,
|
|
196
|
+
gap: state.gap, labels: state.labels, placements: state.placements,
|
|
197
|
+
background: "#f5f5f5", ...extra
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
_relationIndexes() {
|
|
202
|
+
const mode = this.config.relations;
|
|
203
|
+
if (!["selected", "callers", "called"].includes(mode)) return null;
|
|
204
|
+
const atlas = this.atlas;
|
|
205
|
+
if (mode === "selected") {
|
|
206
|
+
if (atlas.selectedRelationIndex != null) return [atlas.selectedRelationIndex];
|
|
207
|
+
if (!atlas.selectedId) return [];
|
|
208
|
+
return atlas.relations.flatMap((relation, index) =>
|
|
209
|
+
relation.from === atlas.selectedId || relation.to === atlas.selectedId ? [index] : []);
|
|
210
|
+
}
|
|
211
|
+
const relation = atlas.selectedRelationIndex == null ? null : atlas.relations[atlas.selectedRelationIndex];
|
|
212
|
+
const seed = atlas.selectedId || (mode === "callers" ? relation?.to : relation?.from);
|
|
213
|
+
return seed ? atlas._recursiveRelationIndexes(seed, mode) : [];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
_focusedIds() {
|
|
217
|
+
if (this.config.focus.mode !== "connected") return null;
|
|
218
|
+
const atlas = this.atlas;
|
|
219
|
+
const focused = new Set();
|
|
220
|
+
if (atlas.selectedId) focused.add(atlas.selectedId);
|
|
221
|
+
else if (atlas.selectedRelationIndex != null) {
|
|
222
|
+
const relation = atlas.relations[atlas.selectedRelationIndex];
|
|
223
|
+
if (relation) { focused.add(relation.from); focused.add(relation.to); }
|
|
224
|
+
}
|
|
225
|
+
if (!focused.size) return null;
|
|
226
|
+
const descendants = id => {
|
|
227
|
+
for (const node of atlas.nodes.values()) if (node.parent === id && !focused.has(node.id)) {
|
|
228
|
+
focused.add(node.id);
|
|
229
|
+
descendants(node.id);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
for (const id of [...focused]) if (["container", "shell"].includes(atlas.nodes.get(id)?.type)) descendants(id);
|
|
233
|
+
const seeds = new Set(focused);
|
|
234
|
+
for (const relation of atlas.relations) if (seeds.has(relation.from) || seeds.has(relation.to)) {
|
|
235
|
+
focused.add(relation.from);
|
|
236
|
+
focused.add(relation.to);
|
|
237
|
+
}
|
|
238
|
+
for (const id of [...focused]) {
|
|
239
|
+
if (["container", "shell"].includes(atlas.nodes.get(id)?.type)) descendants(id);
|
|
240
|
+
let parent = atlas.nodes.get(id)?.parent, guard = 0;
|
|
241
|
+
while (parent && guard++ < atlas.nodes.size) {
|
|
242
|
+
focused.add(parent);
|
|
243
|
+
parent = atlas.nodes.get(parent)?.parent;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return focused;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
refresh() {
|
|
250
|
+
this._refreshQueued = false;
|
|
251
|
+
if (this.atlas.viewMode !== "2d" || !this.atlas.ready || !this.root.clientWidth) return false;
|
|
252
|
+
this._syncControls();
|
|
253
|
+
const options = this.renderOptions({ faceCache: new Map(), background: null, maskGrid: true });
|
|
254
|
+
const viewport = { width: this.root.clientWidth, height: this.root.clientHeight };
|
|
255
|
+
const fit = result => Math.min(1, (viewport.width - 32) / result.width, (viewport.height - 32) / result.height);
|
|
256
|
+
const initial = this.atlas.exportDiagramSvg({ ...options, measureOnly: true });
|
|
257
|
+
let zoom = this.zoom === "fit" ? fit(initial) : this.zoom;
|
|
258
|
+
for (let i = 0; i < (this.zoom === "fit" ? 8 : 1); i += 1) {
|
|
259
|
+
zoom = Math.max(.08, zoom);
|
|
260
|
+
const result = this.atlas.exportDiagramSvg({ ...options, observerScale: zoom, measureOnly: true });
|
|
261
|
+
if (this.zoom !== "fit") break;
|
|
262
|
+
const next = fit(result);
|
|
263
|
+
if (Math.abs(next - zoom) < .002) break;
|
|
264
|
+
zoom = next;
|
|
265
|
+
}
|
|
266
|
+
const result = this.atlas.exportDiagramSvg({ ...options, observerScale: zoom });
|
|
267
|
+
this.result = result;
|
|
268
|
+
const previousX = this.root.scrollLeft, previousY = this.root.scrollTop;
|
|
269
|
+
this.content.innerHTML = result.svg;
|
|
270
|
+
const svg = this.content.querySelector("svg");
|
|
271
|
+
svg.style.width = `${Math.round(result.width * zoom)}px`;
|
|
272
|
+
svg.style.height = `${Math.round(result.height * zoom)}px`;
|
|
273
|
+
this.root.scrollLeft = this.zoom === "fit" ? 0 : previousX;
|
|
274
|
+
this.root.scrollTop = this.zoom === "fit" ? 0 : previousY;
|
|
275
|
+
this._syncSelection();
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
_syncSelection() {
|
|
280
|
+
this.content.querySelectorAll(".selected").forEach(element => element.classList.remove("selected"));
|
|
281
|
+
if (this.atlas.selectedId) for (const element of this.content.querySelectorAll("[data-entity]")) {
|
|
282
|
+
if (element.dataset.entity === this.atlas.selectedId) element.classList.add("selected");
|
|
283
|
+
}
|
|
284
|
+
if (this.atlas.selectedRelation?.id) for (const element of this.content.querySelectorAll("[data-relation]")) {
|
|
285
|
+
if (element.dataset.relation === this.atlas.selectedRelation.id) element.classList.add("selected");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
_syncControls() {
|
|
290
|
+
const toolbar = this.atlas.toolbar;
|
|
291
|
+
this.cube.hidden = !this.config.showOrientationCube;
|
|
292
|
+
this.cube.innerHTML = orientationCubeSvg(cameraAngles(this.config.cameraView));
|
|
293
|
+
toolbar.querySelector("[data-diagram-cube-visible]").checked = this.config.showOrientationCube;
|
|
294
|
+
const orbit = toolbar.querySelector("[data-diagram-orbit]");
|
|
295
|
+
const orbitIds = [...this.atlas.nodes.values()].filter(node => diagramNodeVisible(this.atlas, node) && diagramItemNode(this.atlas, node));
|
|
296
|
+
orbit.innerHTML = '<option value="">Model center</option>' + orbitIds.map(node =>
|
|
297
|
+
'<option value="' + escapeHtml(node.id) + '">' + escapeHtml(node.name || node.id) + "</option>").join("");
|
|
298
|
+
orbit.value = this.config.orbitId || "";
|
|
299
|
+
toolbar.querySelector("[data-diagram-camera-zoom]").value = String(Math.round(this.config.cameraZoom * 100));
|
|
300
|
+
const items = toolbar.querySelector("[data-diagram-items]");
|
|
301
|
+
const visible = new Set(this.config.includeIds || [...this.atlas.nodes.keys()]);
|
|
302
|
+
const nodes = [...this.atlas.nodes.values()].filter(node => diagramNodeVisible(this.atlas, node) && diagramItemNode(this.atlas, node));
|
|
303
|
+
items.innerHTML = nodes.map(node => `<button type="button" class="toolbar-option checkbox" data-diagram-item="${escapeHtml(node.id)}"><input type="checkbox" ${visible.has(node.id) ? "checked" : ""}><span>${escapeHtml(node.name || node.id)}</span></button>`).join("");
|
|
304
|
+
const groupIds = [...new Set(this.atlas.relations.map(relation => relation.group || "default"))];
|
|
305
|
+
const selectedGroups = new Set(this.config.relationGroups || groupIds);
|
|
306
|
+
for (const button of toolbar.querySelectorAll('[data-action="relation-group-toggle"]')) {
|
|
307
|
+
const active = selectedGroups.has(button.dataset.relationGroup);
|
|
308
|
+
button.classList.toggle("active", active);
|
|
309
|
+
button.setAttribute("aria-pressed", String(active));
|
|
310
|
+
}
|
|
311
|
+
toolbar.querySelector("[data-diagram-gap]").value = String(this.config.gap);
|
|
312
|
+
const setActive = (action, active) => {
|
|
313
|
+
const button = toolbar.querySelector(`[data-action="${action}"]`);
|
|
314
|
+
if (!button) return;
|
|
315
|
+
button.classList.toggle("active", active);
|
|
316
|
+
button.setAttribute("aria-pressed", String(active));
|
|
317
|
+
};
|
|
318
|
+
setActive("labels", this.config.labels);
|
|
319
|
+
for (const mode of ["all", "selected", "callers", "called", "none"]) {
|
|
320
|
+
setActive(`relations-${mode}`, this.config.relations === mode);
|
|
321
|
+
}
|
|
322
|
+
setActive("diagram-relations-within", this.config.relations === "within");
|
|
323
|
+
const relationStatus = toolbar.querySelector('[data-toolbar-status="relations"]');
|
|
324
|
+
if (relationStatus) relationStatus.textContent = {
|
|
325
|
+
all: "All", selected: "Selection", callers: "Upstream", called: "Downstream",
|
|
326
|
+
within: "Same container", none: "Hide"
|
|
327
|
+
}[this.config.relations];
|
|
328
|
+
const focus = this.config.focus;
|
|
329
|
+
const focusMode = focus.mode === "all" ? "all" : focus.effect;
|
|
330
|
+
for (const mode of ["all", "dim", "hide"]) setActive(`focus-${mode}`, focusMode === mode);
|
|
331
|
+
setActive("focus-layout", focus.layout === "reflow");
|
|
332
|
+
const focusStatus = toolbar.querySelector('[data-toolbar-status="focus"]');
|
|
333
|
+
if (focusStatus) focusStatus.textContent = focusMode === "all" ? "All" : focusMode === "dim" ? "Connected" : "Isolate";
|
|
334
|
+
const layout = toolbar.querySelector('[data-action="focus-layout"]');
|
|
335
|
+
if (layout) layout.disabled = focus.mode === "all";
|
|
336
|
+
const selection = toolbar.querySelector('[data-action="focus-selection"]');
|
|
337
|
+
if (selection) selection.disabled = !this.atlas.selectedId && this.atlas.selectedRelationIndex == null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
focusSelection() {
|
|
341
|
+
if (this.zoom === "fit" && this.result) {
|
|
342
|
+
const fit = Math.min(1, (this.root.clientWidth - 32) / this.result.width,
|
|
343
|
+
(this.root.clientHeight - 32) / this.result.height);
|
|
344
|
+
this.zoom = clamp(Math.max(.75, fit * 1.8), .08, 4);
|
|
345
|
+
this.refresh();
|
|
346
|
+
}
|
|
347
|
+
const selected = [...this.content.querySelectorAll("[data-entity], [data-relation]")]
|
|
348
|
+
.find(element => element.dataset.entity === this.atlas.selectedId
|
|
349
|
+
|| element.dataset.relation === this.atlas.selectedRelation?.id);
|
|
350
|
+
if (!selected) return;
|
|
351
|
+
const item = selected.getBoundingClientRect();
|
|
352
|
+
const frame = this.root.getBoundingClientRect();
|
|
353
|
+
this.root.scrollLeft += item.left + item.width / 2 - frame.left - frame.width / 2;
|
|
354
|
+
this.root.scrollTop += item.top + item.height / 2 - frame.top - frame.height / 2;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
action(action, control) {
|
|
358
|
+
if (!action) return false;
|
|
359
|
+
if (action === "diagram-fit") { this.zoom = "fit"; this.refresh(); return true; }
|
|
360
|
+
if (action === "diagram-plus" || action === "diagram-minus") {
|
|
361
|
+
const current = this.zoom === "fit" ? Math.min(1, (this.root.clientWidth - 32) / (this.result?.width || 1), (this.root.clientHeight - 32) / (this.result?.height || 1)) : this.zoom;
|
|
362
|
+
this.zoom = clamp(current * (action === "diagram-plus" ? 1.4 : 1 / 1.4), .08, 4);
|
|
363
|
+
this.refresh(); return true;
|
|
364
|
+
}
|
|
365
|
+
if (action === "labels") { this.setConfig({ labels: !this.config.labels }, { layout: true }); return true; }
|
|
366
|
+
if (action === "diagram-cube") { this.setConfig({ showOrientationCube: !this.config.showOrientationCube }); return true; }
|
|
367
|
+
if (action?.startsWith("relations-")) { this.setConfig({ relations: action.slice(10) }); return true; }
|
|
368
|
+
if (action === "relation-group-toggle") {
|
|
369
|
+
const all = [...new Set(this.atlas.relations.map(relation => relation.group || "default"))];
|
|
370
|
+
const included = new Set(this.config.relationGroups || all);
|
|
371
|
+
const id = control?.dataset.relationGroup;
|
|
372
|
+
if (id && included.has(id)) included.delete(id); else if (id) included.add(id);
|
|
373
|
+
this.setConfig({ relationGroups: [...included] });
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (action === "focus-all") { this.setConfig({ focus: { ...this.config.focus, mode: "all" } }); return true; }
|
|
377
|
+
if (action === "focus-dim" || action === "focus-hide") {
|
|
378
|
+
this.setConfig({ focus: { ...this.config.focus, mode: "connected", effect: action.slice(6) } });
|
|
379
|
+
return true;
|
|
380
|
+
}
|
|
381
|
+
if (action === "focus-layout") {
|
|
382
|
+
this.setConfig({ focus: { ...this.config.focus, layout: this.config.focus.layout === "reflow" ? "preserve" : "reflow" } });
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
if (action === "focus-selection") { this.focusSelection(); return true; }
|
|
386
|
+
if (action.startsWith("diagram-relations-")) { this.setConfig({ relations: action.slice(18) }); return true; }
|
|
387
|
+
const angles = cameraAngles(this.config.cameraView);
|
|
388
|
+
const presets = { front: [0, 0], back: [Math.PI, 0], left: [-Math.PI / 2, 0], right: [Math.PI / 2, 0], top: [0, Math.PI / 2 - .015], bottom: [0, -.5 * Math.PI + .015], oblique: [.45, .31] };
|
|
389
|
+
const preset = action.slice(8);
|
|
390
|
+
if (presets[preset] || action === "diagram-turn-left" || action === "diagram-turn-right") {
|
|
391
|
+
const [yaw, pitch] = presets[preset] || [angles.yaw + (action === "diagram-turn-right" ? Math.PI / 12 : -Math.PI / 12), angles.pitch];
|
|
392
|
+
this.setConfig({ cameraView: cameraView(this.config.cameraView.target, yaw, pitch) }, { layout: true }); return true;
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
_bind() {
|
|
398
|
+
this.atlas.addEventListener("lc-select", () => {
|
|
399
|
+
if (this.atlas.viewMode !== "2d") return;
|
|
400
|
+
if (["selected", "callers", "called"].includes(this.config.relations) || this.config.focus.mode === "connected") this.refresh();
|
|
401
|
+
else this._syncSelection();
|
|
402
|
+
});
|
|
403
|
+
this.atlas.addEventListener("lc-model-change", () => this.refresh());
|
|
404
|
+
this.atlas.toolbar.addEventListener("input", event => {
|
|
405
|
+
if (event.target.matches("[data-diagram-gap]")) this.setConfig({ gap: Number(event.target.value) }, { layout: true, defer: true });
|
|
406
|
+
if (event.target.matches("[data-diagram-camera-zoom]")) this.setConfig({ cameraZoom: Number(event.target.value) / 100 }, { layout: true, defer: true });
|
|
407
|
+
});
|
|
408
|
+
this.atlas.toolbar.addEventListener("change", event => {
|
|
409
|
+
if (!event.target.matches("[data-diagram-orbit]")) return;
|
|
410
|
+
const orbitId = event.target.value;
|
|
411
|
+
const target = orbitId ? this.atlas.nodes.get(orbitId)?.group.position.toArray() : modelCenter(this.atlas);
|
|
412
|
+
const angles = cameraAngles(this.config.cameraView);
|
|
413
|
+
this.setConfig({ orbitId, cameraView: cameraView(target || modelCenter(this.atlas), angles.yaw, angles.pitch) }, { layout: true });
|
|
414
|
+
});
|
|
415
|
+
this.atlas.toolbar.addEventListener("click", event => {
|
|
416
|
+
const item = event.target.closest("[data-diagram-item]");
|
|
417
|
+
if (item) {
|
|
418
|
+
const all = [...this.atlas.nodes.keys()];
|
|
419
|
+
const included = new Set(this.config.includeIds || all);
|
|
420
|
+
if (included.has(item.dataset.diagramItem)) included.delete(item.dataset.diagramItem); else included.add(item.dataset.diagramItem);
|
|
421
|
+
this.setConfig({ includeIds: [...included] }, { layout: true });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
const cube = this.cube;
|
|
425
|
+
cube.addEventListener("pointerdown", event => {
|
|
426
|
+
if (event.button !== 0) return;
|
|
427
|
+
this._cubeDrag = { id: event.pointerId, x: event.clientX, y: event.clientY,
|
|
428
|
+
face: event.target.closest?.("[data-cube-face]")?.dataset.cubeFace, moved: false };
|
|
429
|
+
cube.setPointerCapture(event.pointerId);
|
|
430
|
+
});
|
|
431
|
+
cube.addEventListener("pointermove", event => {
|
|
432
|
+
const drag = this._cubeDrag;
|
|
433
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
434
|
+
const dx = event.clientX - drag.x, dy = event.clientY - drag.y;
|
|
435
|
+
if (Math.hypot(dx, dy) < 2) return;
|
|
436
|
+
drag.moved = true; drag.x = event.clientX; drag.y = event.clientY;
|
|
437
|
+
const angles = cameraAngles(this.config.cameraView);
|
|
438
|
+
this.setConfig({ cameraView: cameraView(this.config.cameraView.target,
|
|
439
|
+
angles.yaw - dx * .007, clamp(angles.pitch + dy * .007, -Math.PI / 2 + .015, Math.PI / 2 - .015)) }, { layout: true, defer: true });
|
|
440
|
+
});
|
|
441
|
+
cube.addEventListener("pointerup", event => {
|
|
442
|
+
if (!this._cubeDrag || event.pointerId !== this._cubeDrag.id) return;
|
|
443
|
+
const { face, moved } = this._cubeDrag;
|
|
444
|
+
this._cubeDrag = null;
|
|
445
|
+
if (face && !moved) this.action("diagram-" + face);
|
|
446
|
+
});
|
|
447
|
+
cube.addEventListener("pointercancel", () => { this._cubeDrag = null; });
|
|
448
|
+
cube.addEventListener("keydown", event => {
|
|
449
|
+
const actions = { ArrowLeft: "diagram-turn-left", ArrowRight: "diagram-turn-right", Home: "diagram-front" };
|
|
450
|
+
if (!actions[event.key]) return;
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
this.action(actions[event.key]);
|
|
453
|
+
});
|
|
454
|
+
this.root.addEventListener("click", event => {
|
|
455
|
+
if (this._dragged) { this._dragged = false; return; }
|
|
456
|
+
const entity = event.target.closest?.("[data-entity]");
|
|
457
|
+
const relation = event.target.closest?.("[data-relation]");
|
|
458
|
+
if (entity) this.atlas.select(entity.dataset.entity, { source: "diagram" });
|
|
459
|
+
else if (relation) this.atlas.selectRelation(relation.dataset.relation, { source: "diagram" });
|
|
460
|
+
});
|
|
461
|
+
this.root.addEventListener("pointerdown", event => {
|
|
462
|
+
if (event.button !== 0 && event.button !== 2) return;
|
|
463
|
+
this._dragged = false;
|
|
464
|
+
this._pointer = { id: event.pointerId, x: event.clientX, y: event.clientY };
|
|
465
|
+
});
|
|
466
|
+
this.root.addEventListener("pointermove", event => {
|
|
467
|
+
if (!this._pointer || this._pointer.id !== event.pointerId) return;
|
|
468
|
+
const dx = event.clientX - this._pointer.x, dy = event.clientY - this._pointer.y;
|
|
469
|
+
if (!this._dragged && Math.hypot(dx, dy) > 3) {
|
|
470
|
+
this._dragged = true;
|
|
471
|
+
this.root.setPointerCapture(event.pointerId);
|
|
472
|
+
}
|
|
473
|
+
if (this._dragged) {
|
|
474
|
+
this.root.classList.add("dragging");
|
|
475
|
+
const oldX = this.root.scrollLeft, oldY = this.root.scrollTop;
|
|
476
|
+
this.root.scrollLeft -= dx;
|
|
477
|
+
this.root.scrollTop -= dy;
|
|
478
|
+
const remainingX = dx + this.root.scrollLeft - oldX;
|
|
479
|
+
const remainingY = dy + this.root.scrollTop - oldY;
|
|
480
|
+
if ((remainingX || remainingY) && this.config.cameraZoom > 1) {
|
|
481
|
+
const angles = cameraAngles(this.config.cameraView);
|
|
482
|
+
const zoom = this.zoom === "fit" ? Math.min(1, this.root.clientWidth / (this.result?.width || 1),
|
|
483
|
+
this.root.clientHeight / (this.result?.height || 1)) : this.zoom;
|
|
484
|
+
const target = panTarget(this.config.cameraView.target, angles, remainingX, remainingY, 80 * Math.max(.08, zoom));
|
|
485
|
+
this.setConfig({ cameraView: cameraView(target, angles.yaw, angles.pitch) }, { layout: true, defer: true });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
this._pointer.x = event.clientX; this._pointer.y = event.clientY;
|
|
489
|
+
});
|
|
490
|
+
const stop = () => { this._pointer = null; this.root.classList.remove("dragging"); };
|
|
491
|
+
this.root.addEventListener("pointerup", stop);
|
|
492
|
+
this.root.addEventListener("pointercancel", stop);
|
|
493
|
+
this.root.addEventListener("contextmenu", event => event.preventDefault());
|
|
494
|
+
this.root.addEventListener("wheel", event => {
|
|
495
|
+
if (!event.altKey && !event.ctrlKey) return;
|
|
496
|
+
event.preventDefault();
|
|
497
|
+
const zoom = this.zoom === "fit" ? Math.min(1, this.root.clientWidth / (this.result?.width || 1), this.root.clientHeight / (this.result?.height || 1)) : this.zoom;
|
|
498
|
+
this.zoom = clamp(zoom * (event.deltaY < 0 ? 1.15 : 1 / 1.15), .08, 4);
|
|
499
|
+
this.scheduleRefresh();
|
|
500
|
+
}, { passive: false });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atlas paths are rooted in a document collection. atlas:/ starts at its root;
|
|
3
|
+
* atlas:foo resolves beside the current document. The fragment names an entity.
|
|
4
|
+
*/
|
|
5
|
+
export function resolveAtlasReference(reference, from = null) {
|
|
6
|
+
const raw = String(reference || "").trim();
|
|
7
|
+
if (!raw.startsWith("atlas:")) throw new TypeError("Expected an atlas: reference.");
|
|
8
|
+
const body = raw.slice(6);
|
|
9
|
+
if (body.startsWith("//")) throw new TypeError("Atlas authorities are not supported.");
|
|
10
|
+
const hash = body.indexOf("#");
|
|
11
|
+
const path = hash < 0 ? body : body.slice(0, hash);
|
|
12
|
+
const fragment = hash < 0 ? "" : decodeURIComponent(body.slice(hash + 1));
|
|
13
|
+
const base = from ? resolveAtlasReference(from).uri.slice(6) : "/";
|
|
14
|
+
if (!path && !from) throw new TypeError("An Atlas reference needs a document path.");
|
|
15
|
+
if (!path) return { uri: "atlas:" + base, fragment };
|
|
16
|
+
const segments = (path.startsWith("/") ? [] : base.split("/").slice(0, -1).filter(Boolean));
|
|
17
|
+
for (const part of path.split("/")) {
|
|
18
|
+
if (!part || part === ".") continue;
|
|
19
|
+
if (part === "..") {
|
|
20
|
+
if (!segments.length) throw new TypeError("Atlas reference escapes the collection root.");
|
|
21
|
+
segments.pop();
|
|
22
|
+
} else {
|
|
23
|
+
segments.push(decodeURIComponent(part));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return { uri: "atlas:/" + segments.map(encodeURIComponent).join("/"), fragment };
|
|
27
|
+
}
|