@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.
@@ -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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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
- const bitmap = new Image();
884
- bitmap.crossOrigin = "anonymous";
885
- material.map = new THREE.Texture(bitmap);
886
- let cancelled = false;
887
- let objectUrl = null;
888
- let svgSource = /^\s*<svg(?:\s|>)/i.test(image) || /^data:image\/svg\+xml/i.test(image) || /\.svg(?:[?#]|$)/i.test(image);
889
- const abortController = new AbortController();
890
- const releaseObjectUrl = () => {
891
- if (!objectUrl) return;
892
- URL.revokeObjectURL(objectUrl);
893
- objectUrl = null;
894
- };
895
- const removeListeners = () => {
896
- bitmap.removeEventListener("load", onLoad);
897
- bitmap.removeEventListener("error", onError);
898
- };
899
- const onLoad = () => {
900
- removeListeners();
901
- if (cancelled || !material.map) { releaseObjectUrl(); return; }
902
- if (svgSource) {
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 aspect = (bitmap.naturalWidth || 1) / Math.max(1, bitmap.naturalHeight || 1);
905
- const longestSide = 1024;
906
- const canvas = document.createElement("canvas");
907
- canvas.width = aspect >= 1 ? longestSide : Math.max(1, Math.round(longestSide * aspect));
908
- canvas.height = aspect >= 1 ? Math.max(1, Math.round(longestSide / aspect)) : longestSide;
909
- const context = canvas.getContext("2d");
910
- if (!context) throw new Error("Canvas 2D is unavailable");
911
- context.clearRect(0, 0, canvas.width, canvas.height);
912
- context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
913
- material.map.image = canvas;
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
- reportWarning?.({ code: "SVG_RASTERIZE_FAILED", geometry: definition.id, entityId: entity.id, image, error });
916
- }
917
- }
918
- releaseObjectUrl();
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
- } else if (fit === "contain" && mesh) {
933
- if (imageAspect > faceAspect) mesh.scale.y = baseWidth / imageAspect;
934
- else mesh.scale.x = baseHeight * imageAspect;
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 nextCamera = JSON.stringify(this.modelData.render?.camera || null);
1933
- if (nextCamera !== previousCamera && this.modelData.render?.camera) {
1934
- this.setCameraView(this.modelData.render.camera, { animate: options.animateCamera !== false });
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
- if (this.modelData.render?.camera) this.setCameraView(this.modelData.render.camera, { animate: false });
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.invalidate(), detail => this.dispatchEvent(new CustomEvent("lc-warning", { detail })));
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 = '<button class="back" data-action="overview">← Overview</button>' + 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><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>');
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();