@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
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
|
|
|
@@ -391,7 +402,7 @@ export function focusMovesOutside(container, event) {
|
|
|
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
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="Interactive 3D model"></canvas><div class="toolbar" role="toolbar" aria-label="View controls"><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>`;
|
|
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"];
|
|
@@ -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,6 +2257,7 @@ 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;
|
|
@@ -1916,7 +2266,9 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
1916
2266
|
updateModel(patch = {}, options = {}) {
|
|
1917
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,10 +2281,14 @@ 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
|
|
|
@@ -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;
|
|
@@ -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; }
|
|
@@ -2344,12 +2701,19 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2344
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;
|
|
@@ -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,13 +3207,14 @@ 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 }) => {
|
|
@@ -2854,6 +3224,7 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
2854
3224
|
}).join("");
|
|
2855
3225
|
const connectionLabel = bundle.length === 1 ? "1 connection" : `${bundle.length} connections`;
|
|
2856
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" })));
|
|
@@ -2882,7 +3253,8 @@ class LiquidCarsAtlas extends HTMLElement {
|
|
|
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
|
|
|
@@ -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();
|