@liquidcars/atlas 0.1.47 → 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/demo/index.html +3 -3
- package/demo/model.json +73 -72
- package/dist/liquidcars-atlas.js +2388 -825
- package/dist/liquidcars-atlas.js.map +1 -1
- package/index.html +3 -3
- 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 +589 -160
- package/src/panel-content.js +84 -0
package/src/liquidcars-atlas.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import * as THREE from "./three.module.js";
|
|
2
2
|
import atlasPackage from "../package.json" with { type: "json" };
|
|
3
|
+
import { renderDiagramSvg, diagramSvgToPng } from "./diagram-svg.js";
|
|
4
|
+
import { DiagramView, DIAGRAM_VIEW_CSS, DIAGRAM_TOOLBAR } from "./diagram-view.js";
|
|
5
|
+
|
|
6
|
+
export { renderDiagramSvg, diagramSvgToPng };
|
|
7
|
+
import { resolveAtlasReference } from "./document-navigation.js";
|
|
8
|
+
import { renderPanelContent } from "./panel-content.js";
|
|
3
9
|
|
|
4
10
|
// This value is bundled from the package manifest, so it always identifies
|
|
5
11
|
// the Atlas module that is actually executing rather than the host
|
|
@@ -349,6 +355,8 @@ const CSS = [
|
|
|
349
355
|
|
|
350
356
|
const TOOLBAR_CSS = ".toolbar{max-width:calc(100% - 16px);overflow:visible}.toolbar-group{display:flex;align-items:center;gap:2px}.toolbar-group+.toolbar-group{padding-left:6px;border-left:1px solid #38515f}.toolbar .toolbar-icon{display:grid;width:30px;padding:0;place-items:center}.toolbar-icon svg,.toolbar-menu>summary svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.toolbar-menu{position:relative}.toolbar-menu>summary{display:flex;align-items:center;height:30px;padding:0 8px;gap:6px;border-radius:5px;color:var(--lc-toolbar-text,#dbe7ec);cursor:pointer;list-style:none;user-select:none}.toolbar-menu>summary::-webkit-details-marker{display:none}.toolbar-menu>summary:hover,.toolbar-menu[open]>summary{background:var(--lc-toolbar-active,#1f3d4b);color:var(--lc-toolbar-active-text,#c5f2e9)}.toolbar-menu-status{max-width:92px;overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.toolbar-menu-chevron{width:10px;height:10px!important;transition:transform .16s ease}.toolbar-menu[open] .toolbar-menu-chevron{transform:rotate(180deg)}.toolbar-popover{position:absolute;right:0;bottom:calc(100% + 8px);display:grid;min-width:218px;padding:6px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:9px;background:var(--lc-toolbar-bg,#15232ff7);box-shadow:0 14px 36px #0009}.toolbar-popover-title{padding:7px 9px 5px;color:var(--lc-hint,#8da6b8);font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}.toolbar .toolbar-option{display:grid;grid-template-columns:minmax(0,1fr) 18px;align-items:center;width:100%;height:auto;min-height:34px;padding:7px 9px;text-align:left}.toolbar-option span:first-child{overflow:hidden;text-overflow:ellipsis}.toolbar-option-check{color:var(--lc-toolbar-active-text,#c5f2e9);font-weight:700;text-align:center;visibility:hidden}.toolbar-option.active .toolbar-option-check{visibility:visible}.toolbar-option-separator{height:1px;margin:5px 7px;background:#38515f}.toolbar button[disabled]{opacity:.42;cursor:not-allowed}@media(max-width:620px){.toolbar-menu-status{display:none}.toolbar-menu>summary{width:30px;padding:0;justify-content:center}.toolbar-menu-chevron{display:none}.toolbar-group+.toolbar-group{padding-left:3px}.toolbar-popover{right:-4px}}";
|
|
351
357
|
|
|
358
|
+
const NAVIGATION_CSS = ".toolbar-group[data-toolbar-group=navigation]{position:relative}.toolbar-group[hidden]{display:none}.atlas-history-popover{position:absolute;left:0;bottom:calc(100% + 8px);min-width:250px;max-width:min(360px,80vw);max-height:300px;overflow:auto;padding:6px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:9px;background:var(--lc-toolbar-bg,#15232ff7);box-shadow:0 14px 36px #0009;z-index:12}.atlas-history-popover[hidden]{display:none}.atlas-history-popover .history-heading{padding:7px 9px 5px;color:var(--lc-hint,#8da6b8);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.toolbar .atlas-history-popover button{display:block;width:100%;height:auto;padding:8px 9px;text-align:left;white-space:normal;overflow-wrap:anywhere}.atlas-history-popover small{display:block;opacity:.65;font-size:10px}.atlas-jump{position:absolute;z-index:9;display:grid;place-items:center;width:34px;height:34px;border:1px solid #73d9c7;border-radius:50%;background:#173e49;color:#c8fff2;box-shadow:0 4px 16px #0009;cursor:pointer}.atlas-jump[hidden]{display:none}.atlas-jump:hover,.atlas-jump:focus-visible{background:#286776;outline:2px solid #c8fff2}.atlas-jump svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.panel .rich-content{color:var(--lc-panel-text,#b5c6d2);font-size:14px;line-height:1.55}.panel .rich-content h1,.panel .rich-content h2,.panel .rich-content h3{color:var(--lc-panel-title,#e9f0f5);letter-spacing:normal;margin:14px 0 8px}.panel .rich-content a{color:#6ad7c0}.panel .rich-content pre{overflow:auto;padding:8px;background:#091622;border-radius:5px}.panel .rich-content code{font-size:.9em}.panel .rich-content ul,.panel .rich-content ol{padding-left:20px}";
|
|
359
|
+
const DOCUMENT_NAV_CSS = ".document-navigation{position:absolute;top:12px;right:12px;z-index:10;display:flex;padding:5px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:8px;background:var(--lc-toolbar-bg,#15232fe8)}.document-navigation[hidden],:host([navigation-controls=external]) .document-navigation{display:none!important}.document-navigation button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;border-radius:5px;background:none;color:var(--lc-toolbar-text,#dbe7ec);cursor:pointer}.document-navigation button:hover{background:var(--lc-toolbar-active,#1f3d4b);color:var(--lc-toolbar-active-text,#c5f2e9)}.document-navigation button:disabled{opacity:.42;cursor:not-allowed}.document-navigation svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.document-navigation .atlas-history-popover{top:calc(100% + 8px);right:0;bottom:auto;left:auto}.document-navigation .atlas-history-popover button{display:block;width:100%;height:auto;padding:8px 9px;text-align:left;white-space:normal;overflow-wrap:anywhere}@media print{.document-navigation,.atlas-jump{display:none!important}}";
|
|
352
360
|
const PRINT_CSS = [
|
|
353
361
|
".viewport>.atlas-print-snapshot{display:none!important}",
|
|
354
362
|
"@media print{",
|
|
@@ -370,6 +378,9 @@ const TOOLBAR_ICONS = {
|
|
|
370
378
|
relations: toolbarIcon('<circle cx="5" cy="6" r="2"/><circle cx="19" cy="7" r="2"/><circle cx="12" cy="18" r="2"/><path d="m6.8 6.3 10.2.5M6.2 7.7l4.6 8.6m6.8-7.5-4.4 7.5"/>'),
|
|
371
379
|
focus: toolbarIcon('<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2.5"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>'),
|
|
372
380
|
copy: toolbarIcon('<rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>'),
|
|
381
|
+
home: toolbarIcon('<path d="m3 11 9-7 9 7"/><path d="M5 10v10h14V10M10 20v-6h4v6"/>'),
|
|
382
|
+
back: toolbarIcon('<path d="m15 5-7 7 7 7"/>'),
|
|
383
|
+
forward: toolbarIcon('<path d="m9 5 7 7-7 7"/>'),
|
|
373
384
|
chevron: toolbarIcon('<path d="m8 10 4 4 4-4"/>')
|
|
374
385
|
};
|
|
375
386
|
|
|
@@ -389,9 +400,9 @@ export function focusMovesOutside(container, event) {
|
|
|
389
400
|
return !container?.contains?.(next);
|
|
390
401
|
}
|
|
391
402
|
const toolbarMenu = (id, icon, label, status, content) => `<details class="toolbar-menu" data-toolbar-menu="${id}"><summary aria-label="${label}" title="${label}">${icon}<span class="toolbar-menu-status" data-toolbar-status="${id}" aria-live="polite">${status}</span>${TOOLBAR_ICONS.chevron}</summary><div class="toolbar-popover" role="menu"><div class="toolbar-popover-title">${label}</div>${content}</div></details>`;
|
|
392
|
-
const COPY_TOOLBAR_MARKUP = `<div class="toolbar-group" data-toolbar-group="copy">${toolbarMenu("copy", TOOLBAR_ICONS.copy, "
|
|
403
|
+
const COPY_TOOLBAR_MARKUP = `<div class="toolbar-group" data-toolbar-group="copy">${toolbarMenu("copy", TOOLBAR_ICONS.copy, "Copy image", "PNG", toolbarAction("copy-theme", "PNG with background · HD 2×") + toolbarAction("copy-transparent", "Transparent PNG · HD 2×"))}</div>`;
|
|
393
404
|
|
|
394
|
-
const TEMPLATE = `<div class="atlas"><section class="viewport"><canvas tabindex="0" aria-label="
|
|
405
|
+
const TEMPLATE = `<div class="atlas"><section class="viewport"><canvas tabindex="0" aria-label="Interactive 3D model"></canvas><button class="atlas-jump" data-action="atlas-jump" aria-label="Open linked Atlas" title="Open linked Atlas" hidden><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.1 0l2.1-2.1a5 5 0 0 0-7.1-7.1L10 6"/><path d="M14 11a5 5 0 0 0-7.1 0l-2.1 2.1a5 5 0 0 0 7.1 7.1L14 18"/></svg></button><div class="document-navigation" role="toolbar" aria-label="Atlas document navigation" hidden></div><div class="toolbar" role="toolbar" aria-label="View controls"><div class="toolbar-group" data-toolbar-group="navigation"><button class="toolbar-icon" data-action="atlas-home" aria-label="Atlas home" title="First Atlas document" disabled>${TOOLBAR_ICONS.home}</button><button class="toolbar-icon" data-action="atlas-back" aria-label="Atlas back" title="Back · hold for history" disabled>${TOOLBAR_ICONS.back}</button><button class="toolbar-icon" data-action="atlas-forward" aria-label="Atlas forward" title="Forward · hold for history" disabled>${TOOLBAR_ICONS.forward}</button><div class="atlas-history-popover" role="menu" aria-label="Atlas visits" hidden></div></div><div class="toolbar-group" data-toolbar-group="camera"><button class="toolbar-icon" data-action="minus" aria-label="Zoom out" title="Zoom out">${TOOLBAR_ICONS.minus}</button><button class="toolbar-icon" data-action="plus" aria-label="Zoom in" title="Zoom in">${TOOLBAR_ICONS.plus}</button><button class="toolbar-icon" data-action="fit" aria-label="Fit entire model" title="Fit entire model">${TOOLBAR_ICONS.fit}</button><button class="toolbar-icon" data-action="front" aria-label="Front view" title="Front view">${TOOLBAR_ICONS.front}</button></div><div class="toolbar-group" data-toolbar-group="view">${toolbarMenu("view", TOOLBAR_ICONS.view, "View", "View", toolbarOption("labels", "Labels", true) + toolbarOption("panel", "Information", true) + toolbarOption("item-facing", "Face camera", true) + '<div class="toolbar-option-separator"></div>' + toolbarOption("open-all", "Open all") + toolbarOption("close-all", "Close all"))}</div><div class="toolbar-group" data-toolbar-group="relations">${toolbarMenu("relations", TOOLBAR_ICONS.relations, "Relations", "Selection", toolbarOption("relations-all", "All") + toolbarOption("relations-selected", "Selection", true) + toolbarOption("relations-callers", "Upstream") + toolbarOption("relations-called", "Downstream") + toolbarOption("relations-none", "Hide") + '<div data-relation-groups-section hidden><div class="toolbar-option-separator"></div><div class="toolbar-popover-title">Groups</div><div data-relation-groups></div></div>')}</div><div class="toolbar-group" data-toolbar-group="focus">${toolbarMenu("focus", TOOLBAR_ICONS.focus, "Focus", "All", toolbarOption("focus-all", "All", true) + toolbarOption("focus-dim", "Connected") + toolbarOption("focus-hide", "Isolate") + '<div class="toolbar-option-separator"></div>' + toolbarOption("focus-selection", "Focus selection") + toolbarOption("focus-layout", "Reflow visible"))}</div></div><div class="hint">Rotate: drag · Pan: right button / ⇧+drag / 2 fingers · Zoom: wheel or pinch · Select: click · Focus: double-click</div></section><aside class="panel" aria-live="polite"></aside></div>`;
|
|
395
406
|
|
|
396
407
|
function escapeHtml(value) {
|
|
397
408
|
return String(value ?? "").replace(/[&<>"']/g, char => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
@@ -840,6 +851,57 @@ function partGeometry(part) {
|
|
|
840
851
|
return makeGeometry(part.shape || part.geometry || "box", part.radius, part.segments, part.size);
|
|
841
852
|
}
|
|
842
853
|
|
|
854
|
+
// SVG images are rasterized for Three.js. Reuse a small set of those canvases
|
|
855
|
+
// when Studio replaces one Atlas element with another, so the next diagram can
|
|
856
|
+
// include its images in the very first SVG instead of showing empty planes.
|
|
857
|
+
const geometryImageCache = new Map();
|
|
858
|
+
const MAX_GEOMETRY_IMAGE_PIXELS = 12 * 1024 * 1024;
|
|
859
|
+
let cachedGeometryImagePixels = 0;
|
|
860
|
+
|
|
861
|
+
function cachedGeometryImage(source) {
|
|
862
|
+
const image = geometryImageCache.get(source);
|
|
863
|
+
if (image) {
|
|
864
|
+
geometryImageCache.delete(source);
|
|
865
|
+
geometryImageCache.set(source, image);
|
|
866
|
+
}
|
|
867
|
+
return image;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function rememberGeometryImage(source, canvas, aspect) {
|
|
871
|
+
if (!/^https?:\/\//i.test(source)) return;
|
|
872
|
+
const pixels = canvas.width * canvas.height;
|
|
873
|
+
if (pixels > MAX_GEOMETRY_IMAGE_PIXELS) return;
|
|
874
|
+
const previous = geometryImageCache.get(source);
|
|
875
|
+
if (previous) cachedGeometryImagePixels -= previous.canvas.width * previous.canvas.height;
|
|
876
|
+
geometryImageCache.delete(source);
|
|
877
|
+
geometryImageCache.set(source, { canvas, aspect });
|
|
878
|
+
cachedGeometryImagePixels += pixels;
|
|
879
|
+
while (cachedGeometryImagePixels > MAX_GEOMETRY_IMAGE_PIXELS) {
|
|
880
|
+
const oldest = geometryImageCache.keys().next().value;
|
|
881
|
+
cachedGeometryImagePixels -= geometryImageCache.get(oldest).canvas.width * geometryImageCache.get(oldest).canvas.height;
|
|
882
|
+
geometryImageCache.delete(oldest);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function fitGeometryImage(part, mesh, texture, imageAspect) {
|
|
887
|
+
const fit = part.imageFit || part.fit || "stretch";
|
|
888
|
+
const baseWidth = Math.abs(Number(part.size?.[0])) || 1;
|
|
889
|
+
const baseHeight = Math.abs(Number(part.size?.[1])) || 1;
|
|
890
|
+
const faceAspect = baseWidth / baseHeight;
|
|
891
|
+
if (fit === "cover") {
|
|
892
|
+
if (imageAspect > faceAspect) {
|
|
893
|
+
texture.repeat.set(faceAspect / imageAspect, 1);
|
|
894
|
+
texture.offset.set((1 - texture.repeat.x) / 2, 0);
|
|
895
|
+
} else {
|
|
896
|
+
texture.repeat.set(1, imageAspect / faceAspect);
|
|
897
|
+
texture.offset.set(0, (1 - texture.repeat.y) / 2);
|
|
898
|
+
}
|
|
899
|
+
} else if (fit === "contain" && mesh) {
|
|
900
|
+
if (imageAspect > faceAspect) mesh.scale.y = baseWidth / imageAspect;
|
|
901
|
+
else mesh.scale.x = baseHeight * imageAspect;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
843
905
|
function createGeometryContent(definition, entity, modelPalette, invalidate, reportWarning) {
|
|
844
906
|
const parameters = resolveGeometryParameters(entity, definition);
|
|
845
907
|
const localPalette = {
|
|
@@ -879,110 +941,100 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
|
|
|
879
941
|
let mesh;
|
|
880
942
|
const icon = part.icon;
|
|
881
943
|
const image = part.image;
|
|
944
|
+
let cachedImage = null;
|
|
882
945
|
if (typeof image === "string" && image) {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
946
|
+
cachedImage = cachedGeometryImage(image);
|
|
947
|
+
material.map = new THREE.Texture(cachedImage?.canvas || new Image());
|
|
948
|
+
if (!cachedImage) {
|
|
949
|
+
const bitmap = material.map.image;
|
|
950
|
+
bitmap.crossOrigin = "anonymous";
|
|
951
|
+
let cancelled = false;
|
|
952
|
+
let objectUrl = null;
|
|
953
|
+
let svgSource = /^\s*<svg(?:\s|>)/i.test(image) || /^data:image\/svg\+xml/i.test(image) || /\.svg(?:[?#]|$)/i.test(image);
|
|
954
|
+
const abortController = new AbortController();
|
|
955
|
+
const releaseObjectUrl = () => {
|
|
956
|
+
if (!objectUrl) return;
|
|
957
|
+
URL.revokeObjectURL(objectUrl);
|
|
958
|
+
objectUrl = null;
|
|
959
|
+
};
|
|
960
|
+
const removeListeners = () => {
|
|
961
|
+
bitmap.removeEventListener("load", onLoad);
|
|
962
|
+
bitmap.removeEventListener("error", onError);
|
|
963
|
+
};
|
|
964
|
+
const onLoad = () => {
|
|
965
|
+
removeListeners();
|
|
966
|
+
if (cancelled || !material.map) { releaseObjectUrl(); return; }
|
|
967
|
+
if (svgSource) {
|
|
968
|
+
try {
|
|
969
|
+
const aspect = (bitmap.naturalWidth || 1) / Math.max(1, bitmap.naturalHeight || 1);
|
|
970
|
+
const longestSide = 1024;
|
|
971
|
+
const canvas = document.createElement("canvas");
|
|
972
|
+
canvas.width = aspect >= 1 ? longestSide : Math.max(1, Math.round(longestSide * aspect));
|
|
973
|
+
canvas.height = aspect >= 1 ? Math.max(1, Math.round(longestSide / aspect)) : longestSide;
|
|
974
|
+
const context = canvas.getContext("2d");
|
|
975
|
+
if (!context) throw new Error("Canvas 2D is unavailable");
|
|
976
|
+
context.clearRect(0, 0, canvas.width, canvas.height);
|
|
977
|
+
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
|
978
|
+
material.map.image = canvas;
|
|
979
|
+
rememberGeometryImage(image, canvas, bitmap.naturalWidth / Math.max(1, bitmap.naturalHeight));
|
|
980
|
+
} catch (error) {
|
|
981
|
+
reportWarning?.({ code: "SVG_RASTERIZE_FAILED", geometry: definition.id, entityId: entity.id, image, error });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
releaseObjectUrl();
|
|
985
|
+
const imageAspect = bitmap.naturalWidth / Math.max(1, bitmap.naturalHeight);
|
|
986
|
+
fitGeometryImage(part, mesh, material.map, imageAspect);
|
|
987
|
+
material.map.needsUpdate = true;
|
|
988
|
+
invalidate?.();
|
|
989
|
+
};
|
|
990
|
+
const onError = () => {
|
|
991
|
+
removeListeners();
|
|
992
|
+
releaseObjectUrl();
|
|
993
|
+
if (!cancelled) {
|
|
994
|
+
reportWarning?.({ code: "IMAGE_LOAD_FAILED", geometry: definition.id, entityId: entity.id, image });
|
|
995
|
+
invalidate?.();
|
|
996
|
+
}
|
|
997
|
+
};
|
|
998
|
+
material.userData.atlasImageCleanup = () => {
|
|
999
|
+
cancelled = true;
|
|
1000
|
+
abortController.abort();
|
|
1001
|
+
removeListeners();
|
|
1002
|
+
releaseObjectUrl();
|
|
1003
|
+
bitmap.src = "";
|
|
1004
|
+
};
|
|
1005
|
+
bitmap.addEventListener("load", onLoad);
|
|
1006
|
+
bitmap.addEventListener("error", onError);
|
|
1007
|
+
const assignImageSource = async () => {
|
|
1008
|
+
const inlineSvg = /^\s*<svg(?:\s|>)/i.test(image);
|
|
1009
|
+
if (inlineSvg) {
|
|
1010
|
+
objectUrl = URL.createObjectURL(new Blob([image], { type: "image/svg+xml" }));
|
|
1011
|
+
bitmap.src = objectUrl;
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (/^(?:data|blob):/i.test(image)) {
|
|
1015
|
+
bitmap.src = image;
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
903
1018
|
try {
|
|
904
|
-
const
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
if (
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
1019
|
+
const response = await fetch(image, { mode: "cors", signal: abortController.signal });
|
|
1020
|
+
if (!response.ok) throw new Error(`Image request failed with ${response.status}`);
|
|
1021
|
+
let blob = await response.blob();
|
|
1022
|
+
const contentType = response.headers.get("content-type") || blob.type;
|
|
1023
|
+
const looksLikeSvg = /image\/svg\+xml/i.test(contentType) || /\.svg(?:[?#]|$)/i.test(image);
|
|
1024
|
+
svgSource = svgSource || looksLikeSvg;
|
|
1025
|
+
if (looksLikeSvg && blob.type !== "image/svg+xml") blob = new Blob([await blob.text()], { type: "image/svg+xml" });
|
|
1026
|
+
if (cancelled) return;
|
|
1027
|
+
objectUrl = URL.createObjectURL(blob);
|
|
1028
|
+
bitmap.src = objectUrl;
|
|
914
1029
|
} catch (error) {
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
const fit = part.imageFit || part.fit || "stretch";
|
|
920
|
-
const imageAspect = bitmap.naturalWidth / Math.max(1, bitmap.naturalHeight);
|
|
921
|
-
const baseWidth = Math.abs(Number(part.size?.[0])) || 1;
|
|
922
|
-
const baseHeight = Math.abs(Number(part.size?.[1])) || 1;
|
|
923
|
-
const faceAspect = baseWidth / baseHeight;
|
|
924
|
-
if (fit === "cover") {
|
|
925
|
-
if (imageAspect > faceAspect) {
|
|
926
|
-
material.map.repeat.set(faceAspect / imageAspect, 1);
|
|
927
|
-
material.map.offset.set((1 - material.map.repeat.x) / 2, 0);
|
|
928
|
-
} else {
|
|
929
|
-
material.map.repeat.set(1, imageAspect / faceAspect);
|
|
930
|
-
material.map.offset.set(0, (1 - material.map.repeat.y) / 2);
|
|
1030
|
+
if (cancelled || error?.name === "AbortError") return;
|
|
1031
|
+
// Preserve the previous direct-image behaviour as a fallback. The
|
|
1032
|
+
// server must still permit cross-origin use for a WebGL texture.
|
|
1033
|
+
bitmap.src = image;
|
|
931
1034
|
}
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
}
|
|
936
|
-
material.map.needsUpdate = true;
|
|
937
|
-
invalidate?.();
|
|
938
|
-
};
|
|
939
|
-
const onError = () => {
|
|
940
|
-
removeListeners();
|
|
941
|
-
releaseObjectUrl();
|
|
942
|
-
if (!cancelled) {
|
|
943
|
-
reportWarning?.({ code: "IMAGE_LOAD_FAILED", geometry: definition.id, entityId: entity.id, image });
|
|
944
|
-
invalidate?.();
|
|
945
|
-
}
|
|
946
|
-
};
|
|
947
|
-
material.userData.atlasImageCleanup = () => {
|
|
948
|
-
cancelled = true;
|
|
949
|
-
abortController.abort();
|
|
950
|
-
removeListeners();
|
|
951
|
-
releaseObjectUrl();
|
|
952
|
-
bitmap.src = "";
|
|
953
|
-
};
|
|
954
|
-
bitmap.addEventListener("load", onLoad);
|
|
955
|
-
bitmap.addEventListener("error", onError);
|
|
956
|
-
const assignImageSource = async () => {
|
|
957
|
-
const inlineSvg = /^\s*<svg(?:\s|>)/i.test(image);
|
|
958
|
-
if (inlineSvg) {
|
|
959
|
-
objectUrl = URL.createObjectURL(new Blob([image], { type: "image/svg+xml" }));
|
|
960
|
-
bitmap.src = objectUrl;
|
|
961
|
-
return;
|
|
962
|
-
}
|
|
963
|
-
if (/^(?:data|blob):/i.test(image)) {
|
|
964
|
-
bitmap.src = image;
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
try {
|
|
968
|
-
const response = await fetch(image, { mode: "cors", signal: abortController.signal });
|
|
969
|
-
if (!response.ok) throw new Error(`Image request failed with ${response.status}`);
|
|
970
|
-
let blob = await response.blob();
|
|
971
|
-
const contentType = response.headers.get("content-type") || blob.type;
|
|
972
|
-
const looksLikeSvg = /image\/svg\+xml/i.test(contentType) || /\.svg(?:[?#]|$)/i.test(image);
|
|
973
|
-
svgSource = svgSource || looksLikeSvg;
|
|
974
|
-
if (looksLikeSvg && blob.type !== "image/svg+xml") blob = new Blob([await blob.text()], { type: "image/svg+xml" });
|
|
975
|
-
if (cancelled) return;
|
|
976
|
-
objectUrl = URL.createObjectURL(blob);
|
|
977
|
-
bitmap.src = objectUrl;
|
|
978
|
-
} catch (error) {
|
|
979
|
-
if (cancelled || error?.name === "AbortError") return;
|
|
980
|
-
// Preserve the previous direct-image behaviour as a fallback. The
|
|
981
|
-
// server must still permit cross-origin use for a WebGL texture.
|
|
982
|
-
bitmap.src = image;
|
|
983
|
-
}
|
|
984
|
-
};
|
|
985
|
-
assignImageSource();
|
|
1035
|
+
};
|
|
1036
|
+
assignImageSource();
|
|
1037
|
+
}
|
|
986
1038
|
material.map.colorSpace = THREE.SRGBColorSpace;
|
|
987
1039
|
material.needsUpdate = true;
|
|
988
1040
|
} else if (icon != null && icon !== "") {
|
|
@@ -993,6 +1045,7 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
|
|
|
993
1045
|
mesh = new THREE.Mesh(geometry, material);
|
|
994
1046
|
mesh.position.fromArray(Array.isArray(part.position) ? part.position : [0, 0, 0]);
|
|
995
1047
|
mesh.scale.fromArray(Array.isArray(part.size) ? part.size : [1, 1, 1]);
|
|
1048
|
+
if (cachedImage) fitGeometryImage(part, mesh, material.map, cachedImage.aspect);
|
|
996
1049
|
if (Array.isArray(part.rotation)) mesh.rotation.set(...part.rotation.map(Number));
|
|
997
1050
|
root.add(mesh);
|
|
998
1051
|
meshes.push(mesh);
|
|
@@ -1327,8 +1380,11 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1327
1380
|
constructor() {
|
|
1328
1381
|
super();
|
|
1329
1382
|
this.attachShadow({ mode: "open" });
|
|
1330
|
-
this.shadowRoot.innerHTML = "<style>" + CSS + TOOLBAR_CSS + PRINT_CSS + "</style>" + TEMPLATE;
|
|
1383
|
+
this.shadowRoot.innerHTML = "<style>" + CSS + TOOLBAR_CSS + NAVIGATION_CSS + DOCUMENT_NAV_CSS + PRINT_CSS + DIAGRAM_VIEW_CSS + "</style>" + TEMPLATE;
|
|
1331
1384
|
this.shadowRoot.querySelector('[data-toolbar-group="focus"]')?.insertAdjacentHTML?.("afterend", COPY_TOOLBAR_MARKUP);
|
|
1385
|
+
this.shadowRoot.querySelector('[data-toolbar-group="navigation"]')?.insertAdjacentHTML?.("afterend", '<div class="toolbar-group" data-toolbar-group="mode"><button data-action="view-mode" aria-label="Switch to 2D diagram" title="Switch to 2D diagram">2D</button></div>');
|
|
1386
|
+
this.shadowRoot.querySelector('[data-toolbar-group="camera"]')?.insertAdjacentHTML?.("beforebegin", DIAGRAM_TOOLBAR);
|
|
1387
|
+
this.shadowRoot.querySelector(".viewport")?.insertAdjacentHTML?.("afterbegin", '<div class="diagram-view" aria-label="Interactive 2D diagram" hidden><div class="diagram-content"></div></div>');
|
|
1332
1388
|
this.canvas = this.shadowRoot.querySelector("canvas");
|
|
1333
1389
|
this.panel = this.shadowRoot.querySelector(".panel");
|
|
1334
1390
|
this.scene = new THREE.Scene();
|
|
@@ -1343,6 +1399,15 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1343
1399
|
this.selected = null;
|
|
1344
1400
|
this.selectedRelationIndex = null;
|
|
1345
1401
|
this.modelData = null;
|
|
1402
|
+
this.documentUri = null;
|
|
1403
|
+
this.documentResolver = null;
|
|
1404
|
+
this.contentResolver = null;
|
|
1405
|
+
this._visits = [];
|
|
1406
|
+
this._visitIndex = -1;
|
|
1407
|
+
this._navigationToken = 0;
|
|
1408
|
+
this._contentToken = 0;
|
|
1409
|
+
this._historyPressTimer = null;
|
|
1410
|
+
this._historyLongPress = false;
|
|
1346
1411
|
this._loading = false;
|
|
1347
1412
|
this._pendingSelection = null;
|
|
1348
1413
|
// Custom elements must not add or mutate host attributes while their
|
|
@@ -1376,6 +1441,15 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1376
1441
|
this._toolbarVisible = this.getAttribute("toolbar") !== "off";
|
|
1377
1442
|
this._panelVisible = this.getAttribute("panel") !== "off";
|
|
1378
1443
|
this.toolbar = this.shadowRoot.querySelector(".toolbar");
|
|
1444
|
+
this.viewMode = "3d";
|
|
1445
|
+
this.diagramView = new DiagramView(this);
|
|
1446
|
+
this.navigationToolbar = this.shadowRoot.querySelector(".document-navigation");
|
|
1447
|
+
this.navigationToolbar.append(this.toolbar.querySelector('[data-toolbar-group="navigation"]'));
|
|
1448
|
+
this.jumpButton = this.shadowRoot.querySelector(".atlas-jump");
|
|
1449
|
+
this.historyPopover = this.shadowRoot.querySelector(".atlas-history-popover");
|
|
1450
|
+
this._outsideHistoryListener = event => {
|
|
1451
|
+
if (!event.composedPath().includes(this.navigationToolbar)) this._closeHistoryPopover();
|
|
1452
|
+
};
|
|
1379
1453
|
this._copyStatusTimer = null;
|
|
1380
1454
|
this._printState = null;
|
|
1381
1455
|
this._beforePrintListener = () => this.prepareForPrint();
|
|
@@ -1386,16 +1460,24 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1386
1460
|
if (this.ready && this.modelData) this._build();
|
|
1387
1461
|
};
|
|
1388
1462
|
this._bindControls();
|
|
1463
|
+
this.panel.addEventListener("click", event => {
|
|
1464
|
+
const link = event.target.closest?.("a[href^='atlas:'], [data-action='atlas-link']");
|
|
1465
|
+
if (!link) return;
|
|
1466
|
+
event.preventDefault();
|
|
1467
|
+
void this.openAtlas(link.getAttribute("href") || link.dataset.atlasRef).catch(() => {});
|
|
1468
|
+
});
|
|
1389
1469
|
this._syncFocusToolbar();
|
|
1390
1470
|
this._syncRelationToolbar();
|
|
1391
1471
|
this._syncLabelsToolbar();
|
|
1392
1472
|
this._syncPanelToolbar();
|
|
1393
1473
|
this._syncItemFacingToolbar();
|
|
1474
|
+
this._syncDocumentToolbar();
|
|
1394
1475
|
this.resizeObserver = new ResizeObserver(() => this.resize());
|
|
1395
1476
|
this.resizeObserver.observe(this.canvas);
|
|
1396
1477
|
}
|
|
1397
1478
|
|
|
1398
1479
|
connectedCallback() {
|
|
1480
|
+
globalThis.document?.addEventListener?.("pointerdown", this._outsideHistoryListener);
|
|
1399
1481
|
globalThis.addEventListener?.("lc-geometry-registry-change", this._geometryRegistryListener);
|
|
1400
1482
|
globalThis.addEventListener?.("beforeprint", this._beforePrintListener);
|
|
1401
1483
|
globalThis.addEventListener?.("afterprint", this._afterPrintListener);
|
|
@@ -1407,6 +1489,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1407
1489
|
}
|
|
1408
1490
|
|
|
1409
1491
|
disconnectedCallback() {
|
|
1492
|
+
globalThis.document?.removeEventListener?.("pointerdown", this._outsideHistoryListener);
|
|
1410
1493
|
globalThis.removeEventListener?.("lc-geometry-registry-change", this._geometryRegistryListener);
|
|
1411
1494
|
globalThis.removeEventListener?.("beforeprint", this._beforePrintListener);
|
|
1412
1495
|
globalThis.removeEventListener?.("afterprint", this._afterPrintListener);
|
|
@@ -1414,6 +1497,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1414
1497
|
else this._printMedia?.removeListener?.(this._printMediaListener);
|
|
1415
1498
|
this.restoreAfterPrint();
|
|
1416
1499
|
if (this._copyStatusTimer) globalThis.clearTimeout?.(this._copyStatusTimer);
|
|
1500
|
+
if (this._historyPressTimer) globalThis.clearTimeout?.(this._historyPressTimer);
|
|
1417
1501
|
this.resizeObserver.disconnect();
|
|
1418
1502
|
if (this.animationFrame) cancelAnimationFrame(this.animationFrame);
|
|
1419
1503
|
this._clearObjects();
|
|
@@ -1458,6 +1542,212 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1458
1542
|
get model() { return this.modelData; }
|
|
1459
1543
|
set model(value) { this.load(value); }
|
|
1460
1544
|
|
|
1545
|
+
setDocumentResolver(resolver) {
|
|
1546
|
+
if (resolver !== null && typeof resolver !== "function") throw new TypeError("Document resolver must be a function.");
|
|
1547
|
+
this.documentResolver = resolver;
|
|
1548
|
+
this._syncDocumentToolbar();
|
|
1549
|
+
this._syncJumpButton();
|
|
1550
|
+
return this;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
setContentResolver(resolver) {
|
|
1554
|
+
if (resolver !== null && typeof resolver !== "function") throw new TypeError("Content resolver must be a function.");
|
|
1555
|
+
this.contentResolver = resolver;
|
|
1556
|
+
return this;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
setDocumentUri(uri) {
|
|
1560
|
+
this.documentUri = resolveAtlasReference(uri).uri;
|
|
1561
|
+
this._visits = [this._captureVisit()];
|
|
1562
|
+
this._visitIndex = 0;
|
|
1563
|
+
this._syncDocumentToolbar();
|
|
1564
|
+
this._renderPanel();
|
|
1565
|
+
return this;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
_captureVisit() {
|
|
1569
|
+
return {
|
|
1570
|
+
uri: this.documentUri,
|
|
1571
|
+
model: this.modelData ? structuredClone(this.modelData) : null,
|
|
1572
|
+
camera: this.getCameraView(),
|
|
1573
|
+
open: [...this.open],
|
|
1574
|
+
selection: this.selection
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
async openAtlas(reference) {
|
|
1579
|
+
const { uri, fragment } = resolveAtlasReference(reference, this.documentUri);
|
|
1580
|
+
if (!this.documentResolver) throw new Error("No Atlas document resolver is configured.");
|
|
1581
|
+
const token = ++this._navigationToken;
|
|
1582
|
+
try {
|
|
1583
|
+
const resolved = uri === this.documentUri ? { model: this.modelData, uri } : await this.documentResolver({ uri, from: this.documentUri });
|
|
1584
|
+
if (token !== this._navigationToken) return this;
|
|
1585
|
+
if (!resolved?.model) throw new Error("Resolver did not return a model for " + uri);
|
|
1586
|
+
const destination = resolveAtlasReference(resolved.uri || uri).uri;
|
|
1587
|
+
if (this._visitIndex >= 0) this._visits[this._visitIndex] = this._captureVisit();
|
|
1588
|
+
await this.load(resolved.model, { navigation: true, uri: destination });
|
|
1589
|
+
if (token !== this._navigationToken) return this;
|
|
1590
|
+
this._visits = this._visits.slice(0, this._visitIndex + 1);
|
|
1591
|
+
if (fragment) this.select(fragment, { source: "atlas-link" });
|
|
1592
|
+
this._visits.push(this._captureVisit());
|
|
1593
|
+
this._visitIndex = this._visits.length - 1;
|
|
1594
|
+
this._syncDocumentToolbar();
|
|
1595
|
+
this._renderPanel();
|
|
1596
|
+
this._emit("lc-navigation", { uri: destination, index: this._visitIndex, length: this._visits.length });
|
|
1597
|
+
return this;
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
this._emit("lc-navigation-error", { reference, message: error.message });
|
|
1600
|
+
this.panel?.insertAdjacentHTML?.("afterbegin", '<p class="navigation-error" role="alert">' + escapeHtml(error.message) + "</p>");
|
|
1601
|
+
throw error;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
async _restoreVisit(index) {
|
|
1606
|
+
if (!Number.isInteger(index) || index < 0 || index >= this._visits.length) return this;
|
|
1607
|
+
this._closeHistoryPopover();
|
|
1608
|
+
if (index === this._visitIndex) return this;
|
|
1609
|
+
++this._navigationToken;
|
|
1610
|
+
this._visits[this._visitIndex] = this._captureVisit();
|
|
1611
|
+
const visit = this._visits[index];
|
|
1612
|
+
await this.load(visit.model, { navigation: true, uri: visit.uri });
|
|
1613
|
+
this._visitIndex = index;
|
|
1614
|
+
this._syncDocumentToolbar();
|
|
1615
|
+
this.setOpenContainers(visit.open, { emit: false });
|
|
1616
|
+
if (visit.selection?.kind === "entity") this.select(visit.selection.id, { emit: false, navigation: "preserve" });
|
|
1617
|
+
else if (visit.selection?.kind === "relation") this.selectRelation(visit.selection.id, { emit: false, navigation: "preserve" });
|
|
1618
|
+
this.setCameraView(visit.camera, { animate: false });
|
|
1619
|
+
this._renderPanel();
|
|
1620
|
+
this._emit("lc-navigation", { uri: visit.uri, index, length: this._visits.length });
|
|
1621
|
+
return this;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
goBack() { return this._restoreVisit(this._visitIndex - 1); }
|
|
1625
|
+
goForward() { return this._restoreVisit(this._visitIndex + 1); }
|
|
1626
|
+
goHome() { return this._restoreVisit(0); }
|
|
1627
|
+
goToVisit(index) { return this._restoreVisit(index); }
|
|
1628
|
+
|
|
1629
|
+
getDocumentHistory() {
|
|
1630
|
+
return {
|
|
1631
|
+
index: this._visitIndex,
|
|
1632
|
+
visits: this._visits.map(visit => ({
|
|
1633
|
+
uri: visit.uri,
|
|
1634
|
+
title: visit.model?.title || visit.model?.name || decodeURIComponent((visit.uri || "").split("/").pop() || "Atlas")
|
|
1635
|
+
}))
|
|
1636
|
+
};
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
/** Carry the visit stack to another Atlas view of the same document. */
|
|
1640
|
+
captureNavigationState() {
|
|
1641
|
+
if (this._visitIndex < 0) return null;
|
|
1642
|
+
const visits = structuredClone(this._visits);
|
|
1643
|
+
visits[this._visitIndex] = this._captureVisit();
|
|
1644
|
+
return { visits, index: this._visitIndex };
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
restoreNavigationState(state) {
|
|
1648
|
+
if (!state || !Array.isArray(state.visits) || !Number.isInteger(state.index)
|
|
1649
|
+
|| state.index < 0 || state.index >= state.visits.length
|
|
1650
|
+
|| state.visits[state.index]?.uri !== this.documentUri) {
|
|
1651
|
+
throw new TypeError("Navigation state does not match the current Atlas document.");
|
|
1652
|
+
}
|
|
1653
|
+
this._visits = structuredClone(state.visits);
|
|
1654
|
+
this._visitIndex = state.index;
|
|
1655
|
+
this._visits[this._visitIndex] = this._captureVisit();
|
|
1656
|
+
this._syncDocumentToolbar();
|
|
1657
|
+
return this;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
_syncDocumentToolbar() {
|
|
1661
|
+
const canNavigate = Boolean(this.documentResolver) && this._visitIndex >= 0;
|
|
1662
|
+
const group = this.navigationToolbar?.querySelector('[data-toolbar-group="navigation"]');
|
|
1663
|
+
if (group) group.hidden = !canNavigate;
|
|
1664
|
+
if (this.navigationToolbar) this.navigationToolbar.hidden = !canNavigate || !this.toolbarVisible;
|
|
1665
|
+
for (const [action, enabled] of [
|
|
1666
|
+
["atlas-home", canNavigate && this._visitIndex > 0],
|
|
1667
|
+
["atlas-back", canNavigate && this._visitIndex > 0],
|
|
1668
|
+
["atlas-forward", canNavigate && this._visitIndex < this._visits.length - 1]
|
|
1669
|
+
]) {
|
|
1670
|
+
const button = this.navigationToolbar?.querySelector(`[data-action="${action}"]`);
|
|
1671
|
+
if (button) button.disabled = !enabled;
|
|
1672
|
+
}
|
|
1673
|
+
if (!canNavigate) this._closeHistoryPopover();
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
_closeHistoryPopover() {
|
|
1677
|
+
if (this.historyPopover) this.historyPopover.hidden = true;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
_showHistoryPopover(direction) {
|
|
1681
|
+
const indexes = direction === "back"
|
|
1682
|
+
? Array.from({ length: this._visitIndex }, (_, offset) => this._visitIndex - offset - 1)
|
|
1683
|
+
: Array.from({ length: this._visits.length - this._visitIndex - 1 }, (_, offset) => this._visitIndex + offset + 1);
|
|
1684
|
+
if (!indexes.length || !this.historyPopover) return;
|
|
1685
|
+
const heading = direction === "back" ? "Previous Atlas documents" : "Next Atlas documents";
|
|
1686
|
+
this.historyPopover.innerHTML = '<div class="history-heading">' + heading + '</div>' + indexes.map(index => {
|
|
1687
|
+
const visit = this._visits[index];
|
|
1688
|
+
const uri = visit.uri || "";
|
|
1689
|
+
const basename = decodeURIComponent(uri.split("/").pop() || "Atlas");
|
|
1690
|
+
const title = visit.model?.title || visit.model?.name || basename;
|
|
1691
|
+
return '<button type="button" role="menuitem" data-action="atlas-history-visit" data-visit-index="' + index + '"><span>' + escapeHtml(title) + '</span><small>' + escapeHtml(uri) + '</small></button>';
|
|
1692
|
+
}).join("");
|
|
1693
|
+
this.historyPopover.hidden = false;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
_syncJumpButton() {
|
|
1697
|
+
if (!this.jumpButton) return;
|
|
1698
|
+
const relation = this.selectedRelationIndex == null ? null : this.relations[this.selectedRelationIndex];
|
|
1699
|
+
const target = this.selected || relation;
|
|
1700
|
+
const reference = this.documentResolver && String(target?.atlasRef || "").trim();
|
|
1701
|
+
this.jumpButton.hidden = !reference;
|
|
1702
|
+
if (reference) {
|
|
1703
|
+
this.jumpButton.dataset.atlasRef = reference;
|
|
1704
|
+
this.jumpButton.title = `Open linked Atlas · ${reference}`;
|
|
1705
|
+
this.jumpButton.setAttribute("aria-label", `Open linked Atlas for ${target.name || target.label || "selection"}`);
|
|
1706
|
+
} else delete this.jumpButton.dataset.atlasRef;
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
_positionJumpButton() {
|
|
1710
|
+
if (!this.jumpButton || this.jumpButton.hidden) return;
|
|
1711
|
+
let point;
|
|
1712
|
+
if (this.selected) {
|
|
1713
|
+
if (!this._isVisible(this.selected)) { this.jumpButton.style.visibility = "hidden"; return; }
|
|
1714
|
+
point = this._worldPosition(this.selected).clone();
|
|
1715
|
+
point.y += (this.selected.s?.[1] || 1) / 2 + .25;
|
|
1716
|
+
} else {
|
|
1717
|
+
const relation = this.relations[this.selectedRelationIndex];
|
|
1718
|
+
const from = relation && this.nodes.get(relation.from);
|
|
1719
|
+
const to = relation && this.nodes.get(relation.to);
|
|
1720
|
+
if (!from || !to) { this.jumpButton.style.visibility = "hidden"; return; }
|
|
1721
|
+
point = this._worldPosition(from).add(this._worldPosition(to)).multiplyScalar(.5);
|
|
1722
|
+
point.y += .4;
|
|
1723
|
+
}
|
|
1724
|
+
point.project(this.camera);
|
|
1725
|
+
const width = this.canvas.clientWidth;
|
|
1726
|
+
const height = this.canvas.clientHeight;
|
|
1727
|
+
if (!width || !height || Math.abs(point.x) > 1 || Math.abs(point.y) > 1 || point.z < -1 || point.z > 1) {
|
|
1728
|
+
this.jumpButton.style.visibility = "hidden";
|
|
1729
|
+
return;
|
|
1730
|
+
}
|
|
1731
|
+
this.jumpButton.style.visibility = "visible";
|
|
1732
|
+
this.jumpButton.style.left = Math.max(20, Math.min(width - 20, (point.x + 1) * width / 2 + 20)) + "px";
|
|
1733
|
+
this.jumpButton.style.top = Math.max(20, Math.min(height - 20, (1 - point.y) * height / 2 - 16)) + "px";
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
_renderRichContent(content, title = "") {
|
|
1737
|
+
const target = this.panel.querySelector("[data-rich-content]");
|
|
1738
|
+
const token = ++this._contentToken;
|
|
1739
|
+
if (!target || !content) return;
|
|
1740
|
+
if (content.source) {
|
|
1741
|
+
target.textContent = "Loading content…";
|
|
1742
|
+
if (!this.contentResolver) { target.textContent = "No content resolver is configured."; return; }
|
|
1743
|
+
Promise.resolve().then(() => this.contentResolver({ source: content.source, from: this.documentUri, format: content.format }))
|
|
1744
|
+
.then(value => {
|
|
1745
|
+
if (token === this._contentToken) target.innerHTML = renderPanelContent({ format: content.format, value }, { title });
|
|
1746
|
+
})
|
|
1747
|
+
.catch(error => { if (token === this._contentToken) target.textContent = "Could not load content: " + error.message; });
|
|
1748
|
+
} else target.innerHTML = renderPanelContent(content, { title });
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1461
1751
|
get selectedId() { return this.selected?.id || null; }
|
|
1462
1752
|
get selectedRelation() { return this._publicRelation(this.selectedRelationIndex); }
|
|
1463
1753
|
get selection() {
|
|
@@ -1476,6 +1766,60 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1476
1766
|
return captureRendererFrame(this.renderer, this.scene, this.camera, this.ownerDocument, { ...options, background });
|
|
1477
1767
|
}
|
|
1478
1768
|
|
|
1769
|
+
exportDiagramSvg(options = {}) {
|
|
1770
|
+
return renderDiagramSvg(this, options);
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
getViewMode() { return this.viewMode; }
|
|
1774
|
+
|
|
1775
|
+
setViewMode(mode, options = {}) {
|
|
1776
|
+
if (!["2d", "3d"].includes(mode)) throw new TypeError("Atlas view mode must be '2d' or '3d'.");
|
|
1777
|
+
if (mode === this.viewMode && !options.force) return this;
|
|
1778
|
+
if (mode === "3d" && this.ready && !this._ensureRenderer()) return this;
|
|
1779
|
+
this.viewMode = mode;
|
|
1780
|
+
this.setAttribute("data-view-mode", mode);
|
|
1781
|
+
this.diagramView.root.hidden = mode !== "2d";
|
|
1782
|
+
const button = this.toolbar.querySelector('[data-action="view-mode"]');
|
|
1783
|
+
button.textContent = mode === "2d" ? "3D" : "2D";
|
|
1784
|
+
button.setAttribute("aria-label", mode === "2d" ? "Switch to 3D model" : "Switch to 2D diagram");
|
|
1785
|
+
button.title = button.getAttribute("aria-label");
|
|
1786
|
+
const facing = this.toolbar.querySelector('[data-action="item-facing"]');
|
|
1787
|
+
if (facing) {
|
|
1788
|
+
facing.disabled = mode === "2d";
|
|
1789
|
+
facing.title = mode === "2d" ? "Face camera is available in 3D" : "Face camera";
|
|
1790
|
+
}
|
|
1791
|
+
this.shadowRoot.querySelector(".hint").textContent = mode === "2d"
|
|
1792
|
+
? "Pan: drag · Zoom: + / − or Alt + wheel · Select: click · Orientation and filters: toolbar"
|
|
1793
|
+
: "Rotate: drag · Pan: right button / ⇧+drag / 2 fingers · Zoom: wheel or pinch · Select: click · Focus: double-click";
|
|
1794
|
+
if (mode === "2d") {
|
|
1795
|
+
if (this.animationFrame) {
|
|
1796
|
+
cancelAnimationFrame(this.animationFrame);
|
|
1797
|
+
this.animationFrame = 0;
|
|
1798
|
+
}
|
|
1799
|
+
for (const node of this.nodes.values()) if (node.group) node.group.visible = this._isVisible(node);
|
|
1800
|
+
if (!this.diagramView.refresh()) requestAnimationFrame(() => { if (this.viewMode === "2d") this.diagramView.refresh(); });
|
|
1801
|
+
} else {
|
|
1802
|
+
this._syncLabelsToolbar();
|
|
1803
|
+
this._syncRelationToolbar();
|
|
1804
|
+
this._syncFocusToolbar();
|
|
1805
|
+
this._syncItemFacingToolbar();
|
|
1806
|
+
this.resize();
|
|
1807
|
+
this.invalidate();
|
|
1808
|
+
}
|
|
1809
|
+
if (options.emit !== false) this._emit("lc-view-mode-change", { mode, source: options.source || "api" });
|
|
1810
|
+
return this;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
getDiagramView() { return this.diagramView.state; }
|
|
1814
|
+
captureDiagramView() { return this.diagramView.captureState(); }
|
|
1815
|
+
setDiagramView(state) { this.diagramView.setState(state); return this; }
|
|
1816
|
+
|
|
1817
|
+
async exportDiagramPng(options = {}) {
|
|
1818
|
+
const result = options.svg ? { svg: options.svg } : this.exportDiagramSvg(options);
|
|
1819
|
+
const canvas = await diagramSvgToPng(result.svg, this.ownerDocument);
|
|
1820
|
+
return { ...result, blob: await canvasToPngBlob(canvas) };
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1479
1823
|
async copyFrame(options = {}) {
|
|
1480
1824
|
const background = options.background === "transparent" ? "transparent" : "theme";
|
|
1481
1825
|
const scale = Math.max(1, Math.min(4, Number(options.scale) || 2));
|
|
@@ -1516,6 +1860,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1516
1860
|
}
|
|
1517
1861
|
|
|
1518
1862
|
prepareForPrint() {
|
|
1863
|
+
if (this.viewMode === "2d") return this;
|
|
1519
1864
|
if (this._printState) return this;
|
|
1520
1865
|
const snapshot = this.captureFrame();
|
|
1521
1866
|
const styleProperties = ["height", "min-height", "aspect-ratio", "--lc-height", "--lc-print-aspect"];
|
|
@@ -1541,7 +1886,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1541
1886
|
this.setAttribute("data-lc-print-ready", "true");
|
|
1542
1887
|
this._emit("lc-print-ready", { width: snapshot.width, height: snapshot.height, aspect });
|
|
1543
1888
|
} else {
|
|
1544
|
-
this._emit("lc-print-error", { message: "
|
|
1889
|
+
this._emit("lc-print-error", { message: "Could not capture the WebGL framebuffer." });
|
|
1545
1890
|
}
|
|
1546
1891
|
return this;
|
|
1547
1892
|
}
|
|
@@ -1721,7 +2066,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1721
2066
|
const selection = this.shadowRoot.querySelector('[data-action="focus-selection"]');
|
|
1722
2067
|
if (selection) selection.disabled = !this.selected && this.selectedRelationIndex === null;
|
|
1723
2068
|
const status = this.shadowRoot.querySelector('[data-toolbar-status="focus"]');
|
|
1724
|
-
if (status) status.textContent = active === "all" ? "
|
|
2069
|
+
if (status) status.textContent = active === "all" ? "All" : active === "dim" ? "Connected" : "Isolate";
|
|
1725
2070
|
}
|
|
1726
2071
|
|
|
1727
2072
|
_syncRelationToolbar() {
|
|
@@ -1732,15 +2077,15 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1732
2077
|
const active = key === this.relationMode;
|
|
1733
2078
|
button.classList.toggle("active", active);
|
|
1734
2079
|
button.setAttribute("aria-pressed", String(active));
|
|
1735
|
-
|
|
2080
|
+
button.disabled = false;
|
|
1736
2081
|
}
|
|
1737
2082
|
const status = this.shadowRoot.querySelector('[data-toolbar-status="relations"]');
|
|
1738
2083
|
if (status) status.textContent = {
|
|
1739
|
-
all: "
|
|
1740
|
-
selected: "
|
|
1741
|
-
callers: "
|
|
1742
|
-
called: "
|
|
1743
|
-
none: "
|
|
2084
|
+
all: "All",
|
|
2085
|
+
selected: "Selection",
|
|
2086
|
+
callers: "Upstream",
|
|
2087
|
+
called: "Downstream",
|
|
2088
|
+
none: "Hide"
|
|
1744
2089
|
}[this.relationMode];
|
|
1745
2090
|
const definitions = this.modelData?.relationGroups || [];
|
|
1746
2091
|
const section = this.shadowRoot.querySelector("[data-relation-groups-section]");
|
|
@@ -1803,7 +2148,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1803
2148
|
}
|
|
1804
2149
|
|
|
1805
2150
|
_applyModelRenderOptions() {
|
|
1806
|
-
const render = this.modelData?.render;
|
|
2151
|
+
const render = { ...this.modelData?.render, ...this.modelData?.render?.views?.["3d"] };
|
|
1807
2152
|
if (!render || typeof render !== "object") return this;
|
|
1808
2153
|
if (RELATION_DISPLAY_MODES.includes(render.relationMode) && render.relationMode !== this.relationMode) this.setRelationMode(render.relationMode);
|
|
1809
2154
|
if (normalizeRelationMarker(render.relationMarker) !== this.relationMarker) this.setRelationMarker(render.relationMarker, { emit: false, source: "model" });
|
|
@@ -1863,6 +2208,10 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1863
2208
|
];
|
|
1864
2209
|
const state = { changed: changedContainers.length > 0, previous, current, changedContainers };
|
|
1865
2210
|
if (state.changed) this.open = currentSet;
|
|
2211
|
+
if (state.changed && this.viewMode === "2d" && this.nodes?.size) {
|
|
2212
|
+
for (const node of this.nodes.values()) if (node.group) node.group.visible = this._isVisible(node);
|
|
2213
|
+
this.diagramView?.refresh();
|
|
2214
|
+
}
|
|
1866
2215
|
if ((state.changed || options.forceRefresh) && options.refresh !== false) {
|
|
1867
2216
|
this._renderPanel?.();
|
|
1868
2217
|
this._rebuildRelations?.();
|
|
@@ -1908,15 +2257,18 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1908
2257
|
this.modelData = normalizeModel(this.modelData);
|
|
1909
2258
|
const containerState = this._syncInitialContainerState({ refresh: false, emit: false, source: "model" });
|
|
1910
2259
|
this._build({ preserveSelection: selectedId, preserveRelation: selectedRelationReference });
|
|
2260
|
+
if (this.viewMode === "2d") requestAnimationFrame(() => this.diagramView.refresh());
|
|
1911
2261
|
this._emitContainerStateChange(containerState, { source: "model" });
|
|
1912
2262
|
this._emit("lc-model-change", { kind, model: this.modelData, ...detail });
|
|
1913
2263
|
return this;
|
|
1914
2264
|
}
|
|
1915
2265
|
|
|
1916
2266
|
updateModel(patch = {}, options = {}) {
|
|
1917
|
-
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("
|
|
2267
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("The model patch must be an object.");
|
|
1918
2268
|
const current = this.modelData || normalizeModel({});
|
|
1919
|
-
const previousCamera = JSON.stringify(current.render?.camera || null);
|
|
2269
|
+
const previousCamera = JSON.stringify(current.render?.views?.["3d"]?.camera || current.render?.camera || null);
|
|
2270
|
+
const previousDiagram = JSON.stringify(current.render?.views?.["2d"] || null);
|
|
2271
|
+
const previousInitialMode = current.render?.initialMode;
|
|
1920
2272
|
const next = {
|
|
1921
2273
|
...current,
|
|
1922
2274
|
...patch,
|
|
@@ -1929,35 +2281,39 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1929
2281
|
this.theme = this.modelData.theme;
|
|
1930
2282
|
this._commitModelChange("model-update", { patch }, options);
|
|
1931
2283
|
this._applyModelRenderOptions();
|
|
1932
|
-
const
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2284
|
+
const nextCameraValue = this.modelData.render?.views?.["3d"]?.camera || this.modelData.render?.camera;
|
|
2285
|
+
const nextCamera = JSON.stringify(nextCameraValue || null);
|
|
2286
|
+
if (nextCamera !== previousCamera && nextCameraValue) {
|
|
2287
|
+
this.setCameraView(nextCameraValue, { animate: options.animateCamera !== false });
|
|
2288
|
+
}
|
|
2289
|
+
if (JSON.stringify(this.modelData.render?.views?.["2d"] || null) !== previousDiagram) this.diagramView.setState(this.modelData.render?.views?.["2d"] || {});
|
|
2290
|
+
if (this.modelData.render?.initialMode !== previousInitialMode) this.setViewMode(this.modelData.render?.initialMode === "2d" ? "2d" : "3d", { emit: false, source: "model" });
|
|
2291
|
+
else this.diagramView.refresh();
|
|
1936
2292
|
return this;
|
|
1937
2293
|
}
|
|
1938
2294
|
|
|
1939
2295
|
addEntity(entity, options = {}) {
|
|
1940
|
-
if (!entity || typeof entity !== "object" || !entity.id) throw new TypeError("
|
|
2296
|
+
if (!entity || typeof entity !== "object" || !entity.id) throw new TypeError("The entity must include an id.");
|
|
1941
2297
|
if (!this.modelData) this.modelData = normalizeModel({});
|
|
1942
|
-
if (this.modelData.entities.some(item => item.id === entity.id)) throw new Error(`
|
|
1943
|
-
if (entity.parent && !this.modelData.entities.some(item => item.id === entity.parent)) throw new Error(`
|
|
2298
|
+
if (this.modelData.entities.some(item => item.id === entity.id)) throw new Error(`An entity with id "${entity.id}" already exists.`);
|
|
2299
|
+
if (entity.parent && !this.modelData.entities.some(item => item.id === entity.parent)) throw new Error(`Parent container "${entity.parent}" does not exist.`);
|
|
1944
2300
|
this.modelData.entities = [...this.modelData.entities, { ...entity }];
|
|
1945
2301
|
return this._commitModelChange("entity-add", { entity: this._publicEntity(entity.id) }, options);
|
|
1946
2302
|
}
|
|
1947
2303
|
|
|
1948
2304
|
updateEntity(id, patch = {}, options = {}) {
|
|
1949
|
-
if (!this.modelData) throw new Error("No
|
|
2305
|
+
if (!this.modelData) throw new Error("No model is loaded.");
|
|
1950
2306
|
const index = this.modelData.entities.findIndex(item => item.id === id);
|
|
1951
|
-
if (index < 0) throw new Error(`No
|
|
1952
|
-
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("
|
|
1953
|
-
if (patch.id && patch.id !== id) throw new Error("
|
|
1954
|
-
if (patch.parent && patch.parent !== id && !this.modelData.entities.some(item => item.id === patch.parent)) throw new Error(`
|
|
2307
|
+
if (index < 0) throw new Error(`No entity with id "${id}" exists.`);
|
|
2308
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Entity changes must be an object.");
|
|
2309
|
+
if (patch.id && patch.id !== id) throw new Error("An entity id cannot be changed.");
|
|
2310
|
+
if (patch.parent && patch.parent !== id && !this.modelData.entities.some(item => item.id === patch.parent)) throw new Error(`Parent container "${patch.parent}" does not exist.`);
|
|
1955
2311
|
this.modelData.entities = this.modelData.entities.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch, id } : item);
|
|
1956
2312
|
return this._commitModelChange("entity-update", { id, patch, entity: this._publicEntity(id) }, options);
|
|
1957
2313
|
}
|
|
1958
2314
|
|
|
1959
2315
|
removeEntity(id, options = {}) {
|
|
1960
|
-
if (!this.modelData) throw new Error("No
|
|
2316
|
+
if (!this.modelData) throw new Error("No model is loaded.");
|
|
1961
2317
|
const cascade = options.cascade !== false;
|
|
1962
2318
|
const removed = new Set([id]);
|
|
1963
2319
|
if (cascade) {
|
|
@@ -1969,7 +2325,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1969
2325
|
}
|
|
1970
2326
|
}
|
|
1971
2327
|
}
|
|
1972
|
-
if (!this.modelData.entities.some(item => item.id === id)) throw new Error(`No
|
|
2328
|
+
if (!this.modelData.entities.some(item => item.id === id)) throw new Error(`No entity with id "${id}" exists.`);
|
|
1973
2329
|
const selectedRelation = this.modelData.relations[this.selectedRelationIndex];
|
|
1974
2330
|
this.modelData.entities = this.modelData.entities.filter(item => !removed.has(item.id));
|
|
1975
2331
|
this.modelData.relations = this.modelData.relations.filter(relation => !removed.has(relation.from) && !removed.has(relation.to));
|
|
@@ -1980,30 +2336,30 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1980
2336
|
}
|
|
1981
2337
|
|
|
1982
2338
|
addRelation(relation, options = {}) {
|
|
1983
|
-
if (!relation || typeof relation !== "object" || !relation.from || !relation.to) throw new TypeError("
|
|
1984
|
-
if (!this.modelData) throw new Error("No
|
|
2339
|
+
if (!relation || typeof relation !== "object" || !relation.from || !relation.to) throw new TypeError("The relation must include from and to.");
|
|
2340
|
+
if (!this.modelData) throw new Error("No model is loaded.");
|
|
1985
2341
|
const ids = new Set(this.modelData.entities.map(entity => entity.id));
|
|
1986
|
-
if (!ids.has(relation.from) || !ids.has(relation.to)) throw new Error("
|
|
2342
|
+
if (!ids.has(relation.from) || !ids.has(relation.to)) throw new Error("Both relation endpoints must exist in the model.");
|
|
1987
2343
|
this.modelData.relations = [...this.modelData.relations, { ...relation }];
|
|
1988
2344
|
return this._commitModelChange("relation-add", { relation: { ...relation } }, options);
|
|
1989
2345
|
}
|
|
1990
2346
|
|
|
1991
2347
|
updateRelation(reference, patch = {}, options = {}) {
|
|
1992
|
-
if (!this.modelData) throw new Error("No
|
|
2348
|
+
if (!this.modelData) throw new Error("No model is loaded.");
|
|
1993
2349
|
const index = this._relationIndex(reference);
|
|
1994
|
-
if (index < 0 || index >= this.modelData.relations.length) throw new Error("
|
|
1995
|
-
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("
|
|
2350
|
+
if (index < 0 || index >= this.modelData.relations.length) throw new Error("The requested relation does not exist.");
|
|
2351
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Relation changes must be an object.");
|
|
1996
2352
|
const next = { ...this.modelData.relations[index], ...patch };
|
|
1997
2353
|
const ids = new Set(this.modelData.entities.map(entity => entity.id));
|
|
1998
|
-
if (!ids.has(next.from) || !ids.has(next.to)) throw new Error("
|
|
2354
|
+
if (!ids.has(next.from) || !ids.has(next.to)) throw new Error("Both relation endpoints must exist in the model.");
|
|
1999
2355
|
this.modelData.relations = this.modelData.relations.map((item, itemIndex) => itemIndex === index ? next : item);
|
|
2000
2356
|
return this._commitModelChange("relation-update", { index, patch, relation: { ...next } }, options);
|
|
2001
2357
|
}
|
|
2002
2358
|
|
|
2003
2359
|
removeRelation(reference, options = {}) {
|
|
2004
|
-
if (!this.modelData) throw new Error("No
|
|
2360
|
+
if (!this.modelData) throw new Error("No model is loaded.");
|
|
2005
2361
|
const index = this._relationIndex(reference);
|
|
2006
|
-
if (index < 0 || index >= this.modelData.relations.length) throw new Error("
|
|
2362
|
+
if (index < 0 || index >= this.modelData.relations.length) throw new Error("The requested relation does not exist.");
|
|
2007
2363
|
const [relation] = this.modelData.relations.splice(index, 1);
|
|
2008
2364
|
if (this.selectedRelationIndex === index) this.selectedRelationIndex = null;
|
|
2009
2365
|
else if (this.selectedRelationIndex > index) this.selectedRelationIndex -= 1;
|
|
@@ -2126,7 +2482,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2126
2482
|
}
|
|
2127
2483
|
|
|
2128
2484
|
restoreInitialCamera(options = {}) {
|
|
2129
|
-
const initial = this.modelData?.render?.camera;
|
|
2485
|
+
const initial = this.modelData?.render?.views?.["3d"]?.camera || this.modelData?.render?.camera;
|
|
2130
2486
|
if (initial) return this.setCameraView(initial, options);
|
|
2131
2487
|
this.yaw = .37;
|
|
2132
2488
|
this.pitch = .22;
|
|
@@ -2225,7 +2581,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2225
2581
|
|
|
2226
2582
|
setOpenContainers(ids, options = {}) {
|
|
2227
2583
|
if (ids == null || typeof ids === "string" || typeof ids[Symbol.iterator] !== "function") {
|
|
2228
|
-
throw new TypeError("
|
|
2584
|
+
throw new TypeError("Open containers must be provided as an iterable of ids.");
|
|
2229
2585
|
}
|
|
2230
2586
|
this._replaceOpenContainers(ids, options);
|
|
2231
2587
|
return this;
|
|
@@ -2233,8 +2589,8 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2233
2589
|
|
|
2234
2590
|
setContainerOpen(id, value = true, options = {}) {
|
|
2235
2591
|
const entity = this.modelData?.entities?.find(item => item.id === id);
|
|
2236
|
-
if (!entity) throw new Error(`No
|
|
2237
|
-
if (!isContainerType(entity.type)) throw new TypeError(`
|
|
2592
|
+
if (!entity) throw new Error(`No entity with id "${id}" exists.`);
|
|
2593
|
+
if (!isContainerType(entity.type)) throw new TypeError(`Entity "${id}" is not a container.`);
|
|
2238
2594
|
const next = new Set(this.open);
|
|
2239
2595
|
if (value) next.add(id);
|
|
2240
2596
|
else {
|
|
@@ -2329,6 +2685,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2329
2685
|
// disappear after a theme/model update.
|
|
2330
2686
|
toolbar.style.setProperty("display", toolbarVisible ? "flex" : "none", "important");
|
|
2331
2687
|
}
|
|
2688
|
+
this._syncDocumentToolbar();
|
|
2332
2689
|
if (panel) { panel.dataset.hidden = String(!panelVisible); panel.hidden = !panelVisible; }
|
|
2333
2690
|
this._syncPanelToolbar();
|
|
2334
2691
|
if (hint) { hint.dataset.hidden = String(!hintVisible); hint.hidden = !hintVisible; }
|
|
@@ -2341,15 +2698,22 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2341
2698
|
const script = this.querySelector('script[type="application/json"]');
|
|
2342
2699
|
if (!script) return this.load({ palette: DEFAULT_PALETTE, entities: [], relations: [] });
|
|
2343
2700
|
try { return this.load(JSON.parse(script.textContent)); }
|
|
2344
|
-
catch (error) { this._showError("
|
|
2701
|
+
catch (error) { this._showError("Could not read the JSON model: " + error.message); this.dispatchEvent(new CustomEvent("lc-error", { detail: error })); }
|
|
2345
2702
|
}
|
|
2346
2703
|
|
|
2347
|
-
async load(source) {
|
|
2704
|
+
async load(source, options = {}) {
|
|
2348
2705
|
this._loading = true;
|
|
2349
2706
|
this._pendingSelection = null;
|
|
2350
2707
|
try {
|
|
2351
2708
|
const data = typeof source === "string" ? await fetch(source).then(response => response.json()) : source;
|
|
2352
2709
|
this.modelData = normalizeModel(data);
|
|
2710
|
+
if (!options.navigation) {
|
|
2711
|
+
++this._navigationToken;
|
|
2712
|
+
this.documentUri = options.uri ? resolveAtlasReference(options.uri).uri : null;
|
|
2713
|
+
this._visits = [];
|
|
2714
|
+
this._visitIndex = -1;
|
|
2715
|
+
} else this.documentUri = options.uri || null;
|
|
2716
|
+
this._syncDocumentToolbar();
|
|
2353
2717
|
this.selected = null;
|
|
2354
2718
|
this.selectedRelationIndex = null;
|
|
2355
2719
|
this.open.clear();
|
|
@@ -2361,9 +2725,13 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2361
2725
|
this.theme = this.modelData.theme;
|
|
2362
2726
|
this._applyTheme();
|
|
2363
2727
|
this._syncInitialContainerState({ force: true, refresh: false, emit: false });
|
|
2728
|
+
this.viewMode = this.modelData.render?.initialMode === "2d" ? "2d" : "3d";
|
|
2364
2729
|
this._build();
|
|
2365
2730
|
this._applyModelRenderOptions();
|
|
2366
|
-
|
|
2731
|
+
const render = this.modelData.render || {};
|
|
2732
|
+
if (render.views?.["3d"]?.camera || render.camera) this.setCameraView(render.views?.["3d"]?.camera || render.camera, { animate: false });
|
|
2733
|
+
this.diagramView.setState(render.views?.["2d"] || {});
|
|
2734
|
+
this.setViewMode(render.initialMode === "2d" ? "2d" : "3d", { force: true, emit: false, source: "model" });
|
|
2367
2735
|
this.dispatchEvent(new CustomEvent("lc-ready", { detail: { model: this.modelData } }));
|
|
2368
2736
|
this.dispatchEvent(new CustomEvent("lc-model-change", { detail: { model: this.modelData } }));
|
|
2369
2737
|
const pendingSelection = this._pendingSelection;
|
|
@@ -2373,7 +2741,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2373
2741
|
else if (pendingSelection?.kind === "entity") this.select(pendingSelection.id, pendingSelection.options);
|
|
2374
2742
|
return this;
|
|
2375
2743
|
} catch (error) {
|
|
2376
|
-
this._showError("
|
|
2744
|
+
this._showError("Could not load the model: " + error.message);
|
|
2377
2745
|
this.dispatchEvent(new CustomEvent("lc-error", { detail: error }));
|
|
2378
2746
|
throw error;
|
|
2379
2747
|
} finally {
|
|
@@ -2392,7 +2760,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2392
2760
|
this.resize();
|
|
2393
2761
|
return true;
|
|
2394
2762
|
} catch (error) {
|
|
2395
|
-
this._showError("
|
|
2763
|
+
this._showError("WebGL could not start. Enable hardware acceleration in your browser.");
|
|
2396
2764
|
return false;
|
|
2397
2765
|
}
|
|
2398
2766
|
}
|
|
@@ -2472,7 +2840,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2472
2840
|
}
|
|
2473
2841
|
|
|
2474
2842
|
_build(options = {}) {
|
|
2475
|
-
if (!this._ensureRenderer()) return;
|
|
2843
|
+
if (this.viewMode !== "2d" && !this._ensureRenderer()) return;
|
|
2476
2844
|
const selectedId = options.preserveSelection || null;
|
|
2477
2845
|
const selectedRelationReference = options.preserveRelation ?? null;
|
|
2478
2846
|
this._clearObjects();
|
|
@@ -2496,7 +2864,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2496
2864
|
node.ports = { ...(definition?.anchors || {}), ...(entity.ports || {}) };
|
|
2497
2865
|
if (isCustom) {
|
|
2498
2866
|
try {
|
|
2499
|
-
const content = createGeometryContent(definition, entity, this.modelData.palette, () => this.
|
|
2867
|
+
const content = createGeometryContent(definition, entity, this.modelData.palette, () => this._geometryContentUpdated(), detail => this.dispatchEvent(new CustomEvent("lc-warning", { detail })));
|
|
2500
2868
|
content.root.scale.multiply(new THREE.Vector3().fromArray(entity.s));
|
|
2501
2869
|
node.appearanceMaterials = [];
|
|
2502
2870
|
content.root.traverse(object => {
|
|
@@ -2566,6 +2934,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2566
2934
|
this.selected = selectedId && this.nodes.has(selectedId) ? this.nodes.get(selectedId) : null;
|
|
2567
2935
|
const selectedRelationIndex = this._relationIndex(selectedRelationReference);
|
|
2568
2936
|
this.selectedRelationIndex = !this.selected && selectedRelationIndex >= 0 && selectedRelationIndex < this.relations.length ? selectedRelationIndex : null;
|
|
2937
|
+
if (this.viewMode === "2d") for (const node of this.nodes.values()) node.group.visible = this._isVisible(node);
|
|
2569
2938
|
this._renderPanel();
|
|
2570
2939
|
this._rebuildRelations();
|
|
2571
2940
|
this.ready = true;
|
|
@@ -2838,22 +3207,24 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2838
3207
|
|
|
2839
3208
|
_renderPanel() {
|
|
2840
3209
|
if (!this.modelData) return;
|
|
3210
|
+
this._syncJumpButton();
|
|
2841
3211
|
const selectedRelation = this.selectedRelationIndex === null ? null : this.relations[this.selectedRelationIndex];
|
|
2842
3212
|
if (selectedRelation) {
|
|
2843
3213
|
const mode = relationVisualMode(selectedRelation);
|
|
2844
3214
|
const relationGroup = (this.modelData.relationGroups || []).find(group => group.id === selectedRelation.group);
|
|
2845
3215
|
const relationGroups = new Map((this.modelData.relationGroups || []).map(group => [group.id, group]));
|
|
2846
3216
|
const bundle = this.getRelationBundle(this.selectedRelationIndex);
|
|
2847
|
-
const description = selectedRelation.text ? '<p>' + escapeHtml(selectedRelation.text) + '</p>' : '';
|
|
3217
|
+
const description = selectedRelation.content ? '<div class="rich-content" data-rich-content></div>' : selectedRelation.text ? '<p>' + escapeHtml(selectedRelation.text) + '</p>' : '';
|
|
2848
3218
|
const externalLink = externalLinkMarkup(selectedRelation.url);
|
|
2849
3219
|
const groupMarkup = relationGroup ? '<h3>GRUPO</h3><p>' + escapeHtml(relationGroup.label || relationGroup.id) + '</p>' : '';
|
|
2850
3220
|
const bundleMarkup = bundle.map(({ index, relation }) => {
|
|
2851
3221
|
const group = relationGroups.get(relation.group);
|
|
2852
|
-
const metadata = [group?.label || group?.id || "
|
|
3222
|
+
const metadata = [group?.label || group?.id || "Ungrouped", relationVisualMode(relation)].join(" · ");
|
|
2853
3223
|
return '<button class="relation' + (index === this.selectedRelationIndex ? ' selected' : '') + '" data-action="relation" data-relation-index="' + index + '"><strong>' + escapeHtml(relation.label || `${relation.from} → ${relation.to}`) + '</strong><small>' + escapeHtml(metadata) + '</small></button>';
|
|
2854
3224
|
}).join("");
|
|
2855
|
-
const connectionLabel = bundle.length === 1 ? "1
|
|
2856
|
-
this.panel.innerHTML = '<button class="back" data-action="overview">←
|
|
3225
|
+
const connectionLabel = bundle.length === 1 ? "1 connection" : `${bundle.length} connections`;
|
|
3226
|
+
this.panel.innerHTML = '<button class="back" data-action="overview">← Overview</button><button data-action="focus-selection">◎ Focus selection</button><div class="eyebrow">RELATION</div><h2>' + escapeHtml(selectedRelation.label || `${selectedRelation.from} → ${selectedRelation.to}`) + '</h2><div class="sub">' + escapeHtml(selectedRelation.from) + ' → ' + escapeHtml(selectedRelation.to) + '</div>' + description + externalLink + groupMarkup + '<h3>MODE</h3><p>' + escapeHtml(mode) + '</p><h3>VIRTUAL TUBE</h3><p>' + escapeHtml(`${selectedRelation.from} ↔ ${selectedRelation.to} · ${connectionLabel}`) + '</p>' + bundleMarkup;
|
|
3227
|
+
this._renderRichContent(selectedRelation.content, selectedRelation.label || `${selectedRelation.from} → ${selectedRelation.to}`);
|
|
2857
3228
|
this.panel.querySelector('[data-action="overview"]')?.addEventListener("click", () => this.reset());
|
|
2858
3229
|
this.panel.querySelector('[data-action="focus-selection"]')?.addEventListener("click", () => this.focusSelection());
|
|
2859
3230
|
this.panel.querySelectorAll('[data-action="relation"]').forEach(button => button.addEventListener("click", () => this.selectRelation(Number(button.dataset.relationIndex), { source: "panel" })));
|
|
@@ -2862,7 +3233,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2862
3233
|
}
|
|
2863
3234
|
const node = this.selected;
|
|
2864
3235
|
if (!node) {
|
|
2865
|
-
this.panel.innerHTML = '<div class="eyebrow">LIQUIDCARS ATLAS</div><h2>
|
|
3236
|
+
this.panel.innerHTML = '<div class="eyebrow">LIQUIDCARS ATLAS</div><h2>Explore the model.</h2><p>Select an item to inspect its purpose and relations.</p><p class="note">The component accepts a JSON object with palette, entities and relations.</p>';
|
|
2866
3237
|
this._applyTheme();
|
|
2867
3238
|
return;
|
|
2868
3239
|
}
|
|
@@ -2876,13 +3247,14 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2876
3247
|
path.unshift(cursor);
|
|
2877
3248
|
cursor = cursor.parent ? this.nodes.get(cursor.parent) : null;
|
|
2878
3249
|
}
|
|
2879
|
-
const pathMarkup = '<nav class="crumbs" aria-label="
|
|
3250
|
+
const pathMarkup = '<nav class="crumbs" aria-label="Model path"><button data-action="overview">Model</button>' + path.map((item, index) => '<span>/</span><button data-action="path" data-id="' + escapeHtml(item.id) + '" class="' + (index === path.length - 1 ? 'current' : '') + '">' + escapeHtml(item.name) + '</button>').join("") + '</nav>';
|
|
2880
3251
|
const relationMarkup = relations.map(relation => {
|
|
2881
3252
|
const index = this.relations.indexOf(relation);
|
|
2882
3253
|
const description = relation.text ? '<small>' + escapeHtml(relation.text) + '</small>' : '';
|
|
2883
3254
|
return '<button class="relation" data-action="relation" data-relation-index="' + index + '"><strong>' + escapeHtml(relation.label || (relation.from === node.id ? relation.to : relation.from)) + '</strong><small>' + escapeHtml(relation.from) + ' → ' + escapeHtml(relation.to) + '</small>' + description + '</button>';
|
|
2884
3255
|
}).join("");
|
|
2885
|
-
this.panel.innerHTML =
|
|
3256
|
+
this.panel.innerHTML = pathMarkup + '<button data-action="focus-selection">◎ Focus selection</button><div class="eyebrow">' + escapeHtml(isContainerType(node.type) ? "CONTAINER" : node.type === "actor" ? "EXTERNAL ACTOR" : "COMPONENT") + '</div><h2>' + escapeHtml(node.name) + '</h2><div class="sub">' + escapeHtml(node.sub || "") + '</div>' + (node.content ? '<div class="rich-content" data-rich-content></div>' : '<p>' + escapeHtml(node.text || "") + '</p>') + externalLinkMarkup(node.url) + (isContainerType(node.type) ? '<button data-action="toggle-shell">' + (this.open.has(node.id) ? "Close container" : "Open container") + '</button>' : "") + (children.length ? '<h3>CONTAINS</h3><p>' + children.length + ' internal item' + (children.length === 1 ? '' : 's') + '.</p>' : "") + '<h3>RELATIONS</h3>' + (relationMarkup || '<p>No direct relations.</p>');
|
|
3257
|
+
this._renderRichContent(node.content, node.name);
|
|
2886
3258
|
this.panel.querySelectorAll('[data-action="overview"]').forEach(button => button.addEventListener("click", () => this.reset()));
|
|
2887
3259
|
this.panel.querySelectorAll('[data-action="path"]').forEach(button => button.addEventListener("click", () => this.select(button.dataset.id)));
|
|
2888
3260
|
this.panel.querySelectorAll('[data-action="relation"]').forEach(button => button.addEventListener("click", () => this.selectRelation(Number(button.dataset.relationIndex), { source: "panel" })));
|
|
@@ -3006,6 +3378,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3006
3378
|
}
|
|
3007
3379
|
|
|
3008
3380
|
resize() {
|
|
3381
|
+
if (this.viewMode === "2d") this.diagramView.refresh();
|
|
3009
3382
|
if (!this.renderer) return;
|
|
3010
3383
|
const rect = this.canvas.getBoundingClientRect();
|
|
3011
3384
|
if (!rect.width || !rect.height) return;
|
|
@@ -3017,12 +3390,19 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3017
3390
|
}
|
|
3018
3391
|
|
|
3019
3392
|
invalidate() {
|
|
3020
|
-
if (!this.animationFrame && this.renderer) this.animationFrame = requestAnimationFrame(time => this._renderFrame(time));
|
|
3393
|
+
if (this.viewMode === "3d" && !this.animationFrame && this.renderer) this.animationFrame = requestAnimationFrame(time => this._renderFrame(time));
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
_geometryContentUpdated() {
|
|
3397
|
+
// Images in geometry packs may finish loading after the initial diagram
|
|
3398
|
+
// SVG was generated. Refresh that projection without waking the 3D loop.
|
|
3399
|
+
if (this.viewMode === "2d") this.diagramView.scheduleRefresh();
|
|
3400
|
+
else this.invalidate();
|
|
3021
3401
|
}
|
|
3022
3402
|
|
|
3023
3403
|
_renderFrame(time) {
|
|
3024
3404
|
this.animationFrame = 0;
|
|
3025
|
-
if (!this.renderer) return;
|
|
3405
|
+
if (!this.renderer || this.viewMode !== "3d") return;
|
|
3026
3406
|
const zoomGap = Math.abs(this.targetZoom - this.zoom);
|
|
3027
3407
|
const focusGap = this.targetFocus.distanceTo(this.focus);
|
|
3028
3408
|
this.zoom += (this.targetZoom - this.zoom) * .12;
|
|
@@ -3181,6 +3561,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3181
3561
|
}
|
|
3182
3562
|
}
|
|
3183
3563
|
this.renderer.render(this.scene, this.camera);
|
|
3564
|
+
this._positionJumpButton();
|
|
3184
3565
|
if (zoomGap > .001 || focusGap > .001 || this.relationGroup.children.length || this.drag) this.invalidate();
|
|
3185
3566
|
}
|
|
3186
3567
|
|
|
@@ -3234,10 +3615,10 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3234
3615
|
|
|
3235
3616
|
async _copyFromToolbar(background, control) {
|
|
3236
3617
|
if (control) control.disabled = true;
|
|
3237
|
-
this._setCopyToolbarStatus("
|
|
3618
|
+
this._setCopyToolbarStatus("Copying…");
|
|
3238
3619
|
try {
|
|
3239
3620
|
const result = await this.copyFrame({ background, scale: 2, fallback: "download", source: "toolbar" });
|
|
3240
|
-
this._setCopyToolbarStatus(result.method === "clipboard" ? "
|
|
3621
|
+
this._setCopyToolbarStatus(result.method === "clipboard" ? "Copied" : "Downloaded", "success");
|
|
3241
3622
|
return result;
|
|
3242
3623
|
} catch (error) {
|
|
3243
3624
|
this._setCopyToolbarStatus("Error", "error");
|
|
@@ -3249,6 +3630,38 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3249
3630
|
}
|
|
3250
3631
|
|
|
3251
3632
|
_bindControls() {
|
|
3633
|
+
for (const action of ["atlas-back", "atlas-forward"]) {
|
|
3634
|
+
const button = this.navigationToolbar?.querySelector(`[data-action="${action}"]`);
|
|
3635
|
+
if (!button) continue;
|
|
3636
|
+
let origin = null;
|
|
3637
|
+
const cancel = () => { if (this._historyPressTimer) clearTimeout(this._historyPressTimer); this._historyPressTimer = null; };
|
|
3638
|
+
button.addEventListener("pointerdown", event => {
|
|
3639
|
+
if (button.disabled || event.button !== 0) return;
|
|
3640
|
+
origin = { x: event.clientX, y: event.clientY };
|
|
3641
|
+
cancel();
|
|
3642
|
+
this._historyPressTimer = setTimeout(() => {
|
|
3643
|
+
this._historyPressTimer = null;
|
|
3644
|
+
this._historyLongPress = action;
|
|
3645
|
+
this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
|
|
3646
|
+
}, 480);
|
|
3647
|
+
});
|
|
3648
|
+
button.addEventListener("pointermove", event => {
|
|
3649
|
+
if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 7) cancel();
|
|
3650
|
+
});
|
|
3651
|
+
button.addEventListener("pointerup", () => { origin = null; cancel(); setTimeout(() => { this._historyLongPress = null; }, 100); });
|
|
3652
|
+
button.addEventListener("pointercancel", cancel);
|
|
3653
|
+
button.addEventListener("pointerleave", cancel);
|
|
3654
|
+
button.addEventListener("contextmenu", event => {
|
|
3655
|
+
event.preventDefault();
|
|
3656
|
+
if (!button.disabled) this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
|
|
3657
|
+
});
|
|
3658
|
+
button.addEventListener("keydown", event => {
|
|
3659
|
+
if (event.key !== "ArrowDown" || button.disabled) return;
|
|
3660
|
+
event.preventDefault();
|
|
3661
|
+
this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
|
|
3662
|
+
this.historyPopover?.querySelector("button")?.focus();
|
|
3663
|
+
});
|
|
3664
|
+
}
|
|
3252
3665
|
this.toolbar?.querySelectorAll(".toolbar-menu").forEach(menu => menu.addEventListener("toggle", () => {
|
|
3253
3666
|
if (menu.open) this._closeToolbarMenus(menu);
|
|
3254
3667
|
}));
|
|
@@ -3256,17 +3669,33 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
3256
3669
|
// Safari may leave relatedTarget null because pointer-clicked buttons
|
|
3257
3670
|
// do not necessarily receive focus. Only a known destination outside
|
|
3258
3671
|
// the toolbar may close the <details> menu before click is dispatched.
|
|
3259
|
-
if (focusMovesOutside(this.toolbar, event)) this._closeToolbarMenus();
|
|
3672
|
+
if (focusMovesOutside(this.toolbar, event)) { this._closeToolbarMenus(); this._closeHistoryPopover(); }
|
|
3673
|
+
});
|
|
3674
|
+
this.navigationToolbar?.addEventListener("focusout", event => {
|
|
3675
|
+
if (focusMovesOutside(this.navigationToolbar, event)) this._closeHistoryPopover();
|
|
3260
3676
|
});
|
|
3261
3677
|
this.shadowRoot.addEventListener("keydown", event => {
|
|
3262
3678
|
if (event.key !== "Escape") return;
|
|
3679
|
+
this._closeHistoryPopover();
|
|
3263
3680
|
this._closeToolbarMenus();
|
|
3264
3681
|
this.canvas.focus({ preventScroll: true });
|
|
3265
3682
|
});
|
|
3266
3683
|
this.shadowRoot.addEventListener("click", event => {
|
|
3267
3684
|
const actionControl = closestEventElement(event, "[data-action]");
|
|
3268
3685
|
const action = actionControl?.dataset.action;
|
|
3686
|
+
if (this._historyLongPress === action) { this._historyLongPress = null; return; }
|
|
3687
|
+
if (!closestEventElement(event, ".atlas-history-popover")) this._closeHistoryPopover();
|
|
3269
3688
|
if (!closestEventElement(event, ".toolbar-menu")) this._closeToolbarMenus();
|
|
3689
|
+
if (action === "atlas-home") void this.goHome();
|
|
3690
|
+
if (action === "atlas-back") void this.goBack();
|
|
3691
|
+
if (action === "atlas-forward") void this.goForward();
|
|
3692
|
+
if (action === "atlas-history-visit") { this._closeHistoryPopover(); void this._restoreVisit(Number(actionControl.dataset.visitIndex)); }
|
|
3693
|
+
if (action === "atlas-jump") void this.openAtlas(actionControl.dataset.atlasRef).catch(() => {});
|
|
3694
|
+
if (action === "view-mode") this.setViewMode(this.viewMode === "2d" ? "3d" : "2d", { source: "toolbar" });
|
|
3695
|
+
if (this.viewMode === "2d" && actionControl?.closest?.(".toolbar") && this.diagramView.action(action, actionControl)) {
|
|
3696
|
+
if (actionControl?.closest?.(".toolbar-popover") && action !== "relation-group-toggle") this._closeToolbarMenus();
|
|
3697
|
+
return;
|
|
3698
|
+
}
|
|
3270
3699
|
if (action === "minus") this.zoomOut();
|
|
3271
3700
|
if (action === "plus") this.zoomIn();
|
|
3272
3701
|
if (action === "fit") this.fit();
|