@liquidcars/atlas 0.1.47 → 0.1.49

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,11 @@
1
1
  import * as THREE from "./three.module.js";
2
2
  import atlasPackage from "../package.json" with { type: "json" };
3
+ import { renderDiagramSvg, diagramSvgToPng } from "./diagram-svg.js";
4
+ import { DiagramView, DIAGRAM_VIEW_CSS, DIAGRAM_TOOLBAR } from "./diagram-view.js";
5
+
6
+ export { renderDiagramSvg, diagramSvgToPng };
7
+ import { resolveAtlasReference } from "./document-navigation.js";
8
+ import { renderPanelContent } from "./panel-content.js";
3
9
 
4
10
  // This value is bundled from the package manifest, so it always identifies
5
11
  // the Atlas module that is actually executing rather than the host
@@ -349,6 +355,8 @@ const CSS = [
349
355
 
350
356
  const TOOLBAR_CSS = ".toolbar{max-width:calc(100% - 16px);overflow:visible}.toolbar-group{display:flex;align-items:center;gap:2px}.toolbar-group+.toolbar-group{padding-left:6px;border-left:1px solid #38515f}.toolbar .toolbar-icon{display:grid;width:30px;padding:0;place-items:center}.toolbar-icon svg,.toolbar-menu>summary svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.toolbar-menu{position:relative}.toolbar-menu>summary{display:flex;align-items:center;height:30px;padding:0 8px;gap:6px;border-radius:5px;color:var(--lc-toolbar-text,#dbe7ec);cursor:pointer;list-style:none;user-select:none}.toolbar-menu>summary::-webkit-details-marker{display:none}.toolbar-menu>summary:hover,.toolbar-menu[open]>summary{background:var(--lc-toolbar-active,#1f3d4b);color:var(--lc-toolbar-active-text,#c5f2e9)}.toolbar-menu-status{max-width:92px;overflow:hidden;font-size:11px;text-overflow:ellipsis;white-space:nowrap}.toolbar-menu-chevron{width:10px;height:10px!important;transition:transform .16s ease}.toolbar-menu[open] .toolbar-menu-chevron{transform:rotate(180deg)}.toolbar-popover{position:absolute;right:0;bottom:calc(100% + 8px);display:grid;min-width:218px;padding:6px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:9px;background:var(--lc-toolbar-bg,#15232ff7);box-shadow:0 14px 36px #0009}.toolbar-popover-title{padding:7px 9px 5px;color:var(--lc-hint,#8da6b8);font-size:9px;font-weight:700;letter-spacing:.1em;text-transform:uppercase}.toolbar .toolbar-option{display:grid;grid-template-columns:minmax(0,1fr) 18px;align-items:center;width:100%;height:auto;min-height:34px;padding:7px 9px;text-align:left}.toolbar-option span:first-child{overflow:hidden;text-overflow:ellipsis}.toolbar-option-check{color:var(--lc-toolbar-active-text,#c5f2e9);font-weight:700;text-align:center;visibility:hidden}.toolbar-option.active .toolbar-option-check{visibility:visible}.toolbar-option-separator{height:1px;margin:5px 7px;background:#38515f}.toolbar button[disabled]{opacity:.42;cursor:not-allowed}@media(max-width:620px){.toolbar-menu-status{display:none}.toolbar-menu>summary{width:30px;padding:0;justify-content:center}.toolbar-menu-chevron{display:none}.toolbar-group+.toolbar-group{padding-left:3px}.toolbar-popover{right:-4px}}";
351
357
 
358
+ const NAVIGATION_CSS = ".toolbar-group[data-toolbar-group=navigation]{position:relative}.toolbar-group[hidden]{display:none}.atlas-history-popover{position:absolute;left:0;bottom:calc(100% + 8px);min-width:250px;max-width:min(360px,80vw);max-height:300px;overflow:auto;padding:6px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:9px;background:var(--lc-toolbar-bg,#15232ff7);box-shadow:0 14px 36px #0009;z-index:12}.atlas-history-popover[hidden]{display:none}.atlas-history-popover .history-heading{padding:7px 9px 5px;color:var(--lc-hint,#8da6b8);font-size:10px;text-transform:uppercase;letter-spacing:.1em}.toolbar .atlas-history-popover button{display:block;width:100%;height:auto;padding:8px 9px;text-align:left;white-space:normal;overflow-wrap:anywhere}.atlas-history-popover small{display:block;opacity:.65;font-size:10px}.atlas-jump{position:absolute;z-index:9;display:grid;place-items:center;width:34px;height:34px;border:1px solid #73d9c7;border-radius:50%;background:#173e49;color:#c8fff2;box-shadow:0 4px 16px #0009;cursor:pointer}.atlas-jump[hidden]{display:none}.atlas-jump:hover,.atlas-jump:focus-visible{background:#286776;outline:2px solid #c8fff2}.atlas-jump svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.panel .rich-content{color:var(--lc-panel-text,#b5c6d2);font-size:14px;line-height:1.55}.panel .rich-content h1,.panel .rich-content h2,.panel .rich-content h3{color:var(--lc-panel-title,#e9f0f5);letter-spacing:normal;margin:14px 0 8px}.panel .rich-content a{color:#6ad7c0}.panel .rich-content pre{overflow:auto;padding:8px;background:#091622;border-radius:5px}.panel .rich-content code{font-size:.9em}.panel .rich-content ul,.panel .rich-content ol{padding-left:20px}";
359
+ const DOCUMENT_NAV_CSS = ".document-navigation{position:absolute;top:12px;right:12px;z-index:10;display:flex;padding:5px;border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:8px;background:var(--lc-toolbar-bg,#15232fe8)}.document-navigation[hidden],:host([navigation-controls=external]) .document-navigation{display:none!important}.document-navigation button{display:grid;width:30px;height:30px;place-items:center;padding:0;border:0;border-radius:5px;background:none;color:var(--lc-toolbar-text,#dbe7ec);cursor:pointer}.document-navigation button:hover{background:var(--lc-toolbar-active,#1f3d4b);color:var(--lc-toolbar-active-text,#c5f2e9)}.document-navigation button:disabled{opacity:.42;cursor:not-allowed}.document-navigation svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.document-navigation .atlas-history-popover{top:calc(100% + 8px);right:0;bottom:auto;left:auto}.document-navigation .atlas-history-popover button{display:block;width:100%;height:auto;padding:8px 9px;text-align:left;white-space:normal;overflow-wrap:anywhere}@media print{.document-navigation,.atlas-jump{display:none!important}}";
352
360
  const PRINT_CSS = [
353
361
  ".viewport>.atlas-print-snapshot{display:none!important}",
354
362
  "@media print{",
@@ -370,6 +378,9 @@ const TOOLBAR_ICONS = {
370
378
  relations: toolbarIcon('<circle cx="5" cy="6" r="2"/><circle cx="19" cy="7" r="2"/><circle cx="12" cy="18" r="2"/><path d="m6.8 6.3 10.2.5M6.2 7.7l4.6 8.6m6.8-7.5-4.4 7.5"/>'),
371
379
  focus: toolbarIcon('<circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="2.5"/><path d="M12 2v3M12 19v3M2 12h3M19 12h3"/>'),
372
380
  copy: toolbarIcon('<rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/>'),
381
+ home: toolbarIcon('<path d="m3 11 9-7 9 7"/><path d="M5 10v10h14V10M10 20v-6h4v6"/>'),
382
+ back: toolbarIcon('<path d="m15 5-7 7 7 7"/>'),
383
+ forward: toolbarIcon('<path d="m9 5 7 7-7 7"/>'),
373
384
  chevron: toolbarIcon('<path d="m8 10 4 4 4-4"/>')
374
385
  };
375
386
 
@@ -389,9 +400,9 @@ export function focusMovesOutside(container, event) {
389
400
  return !container?.contains?.(next);
390
401
  }
391
402
  const toolbarMenu = (id, icon, label, status, content) => `<details class="toolbar-menu" data-toolbar-menu="${id}"><summary aria-label="${label}" title="${label}">${icon}<span class="toolbar-menu-status" data-toolbar-status="${id}" aria-live="polite">${status}</span>${TOOLBAR_ICONS.chevron}</summary><div class="toolbar-popover" role="menu"><div class="toolbar-popover-title">${label}</div>${content}</div></details>`;
392
- const COPY_TOOLBAR_MARKUP = `<div class="toolbar-group" data-toolbar-group="copy">${toolbarMenu("copy", TOOLBAR_ICONS.copy, "Copiar imagen", "PNG", toolbarAction("copy-theme", "PNG con fondo · HD 2×") + toolbarAction("copy-transparent", "PNG transparente · HD 2×"))}</div>`;
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="Modelo 3D interactivo"></canvas><div class="toolbar" role="toolbar" aria-label="Controles de la vista"><div class="toolbar-group" data-toolbar-group="camera"><button class="toolbar-icon" data-action="minus" aria-label="Alejar" title="Alejar">${TOOLBAR_ICONS.minus}</button><button class="toolbar-icon" data-action="plus" aria-label="Acercar" title="Acercar">${TOOLBAR_ICONS.plus}</button><button class="toolbar-icon" data-action="fit" aria-label="Encuadrar todo el modelo" title="Encuadrar todo el modelo">${TOOLBAR_ICONS.fit}</button><button class="toolbar-icon" data-action="front" aria-label="Vista frontal" title="Vista frontal">${TOOLBAR_ICONS.front}</button></div><div class="toolbar-group" data-toolbar-group="view">${toolbarMenu("view", TOOLBAR_ICONS.view, "Vista", "Vista", toolbarOption("labels", "Etiquetas", true) + toolbarOption("panel", "Información", true) + toolbarOption("item-facing", "Caras a cámara", true) + '<div class="toolbar-option-separator"></div>' + toolbarOption("open-all", "Abrir todos") + toolbarOption("close-all", "Cerrar todos"))}</div><div class="toolbar-group" data-toolbar-group="relations">${toolbarMenu("relations", TOOLBAR_ICONS.relations, "Relaciones", "Selección", toolbarOption("relations-all", "Todas") + toolbarOption("relations-selected", "Selección", true) + toolbarOption("relations-callers", "All callers") + toolbarOption("relations-called", "All called") + toolbarOption("relations-none", "Ocultar") + '<div data-relation-groups-section hidden><div class="toolbar-option-separator"></div><div class="toolbar-popover-title">Grupos</div><div data-relation-groups></div></div>')}</div><div class="toolbar-group" data-toolbar-group="focus">${toolbarMenu("focus", TOOLBAR_ICONS.focus, "Foco", "Todo", toolbarOption("focus-all", "Todo", true) + toolbarOption("focus-dim", "Conectados") + toolbarOption("focus-hide", "Aislar") + '<div class="toolbar-option-separator"></div>' + toolbarOption("focus-selection", "Enfocar selección") + toolbarOption("focus-layout", "Reorganizar visibles"))}</div></div><div class="hint">Gira: arrastra · Pan: botón derecho / ⇧+arrastra / 2 dedos · Zoom: rueda o pellizco · Selecciona: clic · Enfoca: doble clic</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"];
@@ -1541,7 +1886,7 @@ class LiquidCarsAtlas extends HTMLElement {
1541
1886
  this.setAttribute("data-lc-print-ready", "true");
1542
1887
  this._emit("lc-print-ready", { width: snapshot.width, height: snapshot.height, aspect });
1543
1888
  } else {
1544
- this._emit("lc-print-error", { message: "No se pudo capturar el framebuffer WebGL." });
1889
+ this._emit("lc-print-error", { message: "Could not capture the WebGL framebuffer." });
1545
1890
  }
1546
1891
  return this;
1547
1892
  }
@@ -1721,7 +2066,7 @@ class LiquidCarsAtlas extends HTMLElement {
1721
2066
  const selection = this.shadowRoot.querySelector('[data-action="focus-selection"]');
1722
2067
  if (selection) selection.disabled = !this.selected && this.selectedRelationIndex === null;
1723
2068
  const status = this.shadowRoot.querySelector('[data-toolbar-status="focus"]');
1724
- if (status) status.textContent = active === "all" ? "Todo" : active === "dim" ? "Conectados" : "Aislar";
2069
+ if (status) status.textContent = active === "all" ? "All" : active === "dim" ? "Connected" : "Isolate";
1725
2070
  }
1726
2071
 
1727
2072
  _syncRelationToolbar() {
@@ -1732,15 +2077,15 @@ class LiquidCarsAtlas extends HTMLElement {
1732
2077
  const active = key === this.relationMode;
1733
2078
  button.classList.toggle("active", active);
1734
2079
  button.setAttribute("aria-pressed", String(active));
1735
- if (["selected", "callers", "called"].includes(key)) button.disabled = !this.selected && this.selectedRelationIndex === null;
2080
+ button.disabled = false;
1736
2081
  }
1737
2082
  const status = this.shadowRoot.querySelector('[data-toolbar-status="relations"]');
1738
2083
  if (status) status.textContent = {
1739
- all: "Todas",
1740
- selected: "Selección",
1741
- callers: "All callers",
1742
- called: "All called",
1743
- none: "Ocultar"
2084
+ all: "All",
2085
+ selected: "Selection",
2086
+ callers: "Upstream",
2087
+ called: "Downstream",
2088
+ none: "Hide"
1744
2089
  }[this.relationMode];
1745
2090
  const definitions = this.modelData?.relationGroups || [];
1746
2091
  const section = this.shadowRoot.querySelector("[data-relation-groups-section]");
@@ -1803,7 +2148,7 @@ class LiquidCarsAtlas extends HTMLElement {
1803
2148
  }
1804
2149
 
1805
2150
  _applyModelRenderOptions() {
1806
- const render = this.modelData?.render;
2151
+ const render = { ...this.modelData?.render, ...this.modelData?.render?.views?.["3d"] };
1807
2152
  if (!render || typeof render !== "object") return this;
1808
2153
  if (RELATION_DISPLAY_MODES.includes(render.relationMode) && render.relationMode !== this.relationMode) this.setRelationMode(render.relationMode);
1809
2154
  if (normalizeRelationMarker(render.relationMarker) !== this.relationMarker) this.setRelationMarker(render.relationMarker, { emit: false, source: "model" });
@@ -1863,6 +2208,10 @@ class LiquidCarsAtlas extends HTMLElement {
1863
2208
  ];
1864
2209
  const state = { changed: changedContainers.length > 0, previous, current, changedContainers };
1865
2210
  if (state.changed) this.open = currentSet;
2211
+ if (state.changed && this.viewMode === "2d" && this.nodes?.size) {
2212
+ for (const node of this.nodes.values()) if (node.group) node.group.visible = this._isVisible(node);
2213
+ this.diagramView?.refresh();
2214
+ }
1866
2215
  if ((state.changed || options.forceRefresh) && options.refresh !== false) {
1867
2216
  this._renderPanel?.();
1868
2217
  this._rebuildRelations?.();
@@ -1908,15 +2257,18 @@ class LiquidCarsAtlas extends HTMLElement {
1908
2257
  this.modelData = normalizeModel(this.modelData);
1909
2258
  const containerState = this._syncInitialContainerState({ refresh: false, emit: false, source: "model" });
1910
2259
  this._build({ preserveSelection: selectedId, preserveRelation: selectedRelationReference });
2260
+ if (this.viewMode === "2d") requestAnimationFrame(() => this.diagramView.refresh());
1911
2261
  this._emitContainerStateChange(containerState, { source: "model" });
1912
2262
  this._emit("lc-model-change", { kind, model: this.modelData, ...detail });
1913
2263
  return this;
1914
2264
  }
1915
2265
 
1916
2266
  updateModel(patch = {}, options = {}) {
1917
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("El parche del modelo debe ser un objeto.");
2267
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("The model patch must be an object.");
1918
2268
  const current = this.modelData || normalizeModel({});
1919
- const previousCamera = JSON.stringify(current.render?.camera || null);
2269
+ const previousCamera = JSON.stringify(current.render?.views?.["3d"]?.camera || current.render?.camera || null);
2270
+ const previousDiagram = JSON.stringify(current.render?.views?.["2d"] || null);
2271
+ const previousInitialMode = current.render?.initialMode;
1920
2272
  const next = {
1921
2273
  ...current,
1922
2274
  ...patch,
@@ -1929,35 +2281,39 @@ class LiquidCarsAtlas extends HTMLElement {
1929
2281
  this.theme = this.modelData.theme;
1930
2282
  this._commitModelChange("model-update", { patch }, options);
1931
2283
  this._applyModelRenderOptions();
1932
- const 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
 
1939
2295
  addEntity(entity, options = {}) {
1940
- if (!entity || typeof entity !== "object" || !entity.id) throw new TypeError("La entidad debe incluir un id.");
2296
+ if (!entity || typeof entity !== "object" || !entity.id) throw new TypeError("The entity must include an id.");
1941
2297
  if (!this.modelData) this.modelData = normalizeModel({});
1942
- if (this.modelData.entities.some(item => item.id === entity.id)) throw new Error(`Ya existe una entidad con id "${entity.id}".`);
1943
- if (entity.parent && !this.modelData.entities.some(item => item.id === entity.parent)) throw new Error(`No existe el contenedor padre "${entity.parent}".`);
2298
+ if (this.modelData.entities.some(item => item.id === entity.id)) throw new Error(`An entity with id "${entity.id}" already exists.`);
2299
+ if (entity.parent && !this.modelData.entities.some(item => item.id === entity.parent)) throw new Error(`Parent container "${entity.parent}" does not exist.`);
1944
2300
  this.modelData.entities = [...this.modelData.entities, { ...entity }];
1945
2301
  return this._commitModelChange("entity-add", { entity: this._publicEntity(entity.id) }, options);
1946
2302
  }
1947
2303
 
1948
2304
  updateEntity(id, patch = {}, options = {}) {
1949
- if (!this.modelData) throw new Error("No hay ningún modelo cargado.");
2305
+ if (!this.modelData) throw new Error("No model is loaded.");
1950
2306
  const index = this.modelData.entities.findIndex(item => item.id === id);
1951
- if (index < 0) throw new Error(`No existe una entidad con id "${id}".`);
1952
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Los cambios de una entidad deben ser un objeto.");
1953
- if (patch.id && patch.id !== id) throw new Error("El id de una entidad no se puede modificar.");
1954
- if (patch.parent && patch.parent !== id && !this.modelData.entities.some(item => item.id === patch.parent)) throw new Error(`No existe el contenedor padre "${patch.parent}".`);
2307
+ if (index < 0) throw new Error(`No entity with id "${id}" exists.`);
2308
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Entity changes must be an object.");
2309
+ if (patch.id && patch.id !== id) throw new Error("An entity id cannot be changed.");
2310
+ if (patch.parent && patch.parent !== id && !this.modelData.entities.some(item => item.id === patch.parent)) throw new Error(`Parent container "${patch.parent}" does not exist.`);
1955
2311
  this.modelData.entities = this.modelData.entities.map((item, itemIndex) => itemIndex === index ? { ...item, ...patch, id } : item);
1956
2312
  return this._commitModelChange("entity-update", { id, patch, entity: this._publicEntity(id) }, options);
1957
2313
  }
1958
2314
 
1959
2315
  removeEntity(id, options = {}) {
1960
- if (!this.modelData) throw new Error("No hay ningún modelo cargado.");
2316
+ if (!this.modelData) throw new Error("No model is loaded.");
1961
2317
  const cascade = options.cascade !== false;
1962
2318
  const removed = new Set([id]);
1963
2319
  if (cascade) {
@@ -1969,7 +2325,7 @@ class LiquidCarsAtlas extends HTMLElement {
1969
2325
  }
1970
2326
  }
1971
2327
  }
1972
- if (!this.modelData.entities.some(item => item.id === id)) throw new Error(`No existe una entidad con id "${id}".`);
2328
+ if (!this.modelData.entities.some(item => item.id === id)) throw new Error(`No entity with id "${id}" exists.`);
1973
2329
  const selectedRelation = this.modelData.relations[this.selectedRelationIndex];
1974
2330
  this.modelData.entities = this.modelData.entities.filter(item => !removed.has(item.id));
1975
2331
  this.modelData.relations = this.modelData.relations.filter(relation => !removed.has(relation.from) && !removed.has(relation.to));
@@ -1980,30 +2336,30 @@ class LiquidCarsAtlas extends HTMLElement {
1980
2336
  }
1981
2337
 
1982
2338
  addRelation(relation, options = {}) {
1983
- if (!relation || typeof relation !== "object" || !relation.from || !relation.to) throw new TypeError("La relación debe incluir from y to.");
1984
- if (!this.modelData) throw new Error("No hay ningún modelo cargado.");
2339
+ if (!relation || typeof relation !== "object" || !relation.from || !relation.to) throw new TypeError("The relation must include from and to.");
2340
+ if (!this.modelData) throw new Error("No model is loaded.");
1985
2341
  const ids = new Set(this.modelData.entities.map(entity => entity.id));
1986
- if (!ids.has(relation.from) || !ids.has(relation.to)) throw new Error("Los extremos de la relación deben existir en el modelo.");
2342
+ if (!ids.has(relation.from) || !ids.has(relation.to)) throw new Error("Both relation endpoints must exist in the model.");
1987
2343
  this.modelData.relations = [...this.modelData.relations, { ...relation }];
1988
2344
  return this._commitModelChange("relation-add", { relation: { ...relation } }, options);
1989
2345
  }
1990
2346
 
1991
2347
  updateRelation(reference, patch = {}, options = {}) {
1992
- if (!this.modelData) throw new Error("No hay ningún modelo cargado.");
2348
+ if (!this.modelData) throw new Error("No model is loaded.");
1993
2349
  const index = this._relationIndex(reference);
1994
- if (index < 0 || index >= this.modelData.relations.length) throw new Error("No existe la relación indicada.");
1995
- if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Los cambios de una relación deben ser un objeto.");
2350
+ if (index < 0 || index >= this.modelData.relations.length) throw new Error("The requested relation does not exist.");
2351
+ if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new TypeError("Relation changes must be an object.");
1996
2352
  const next = { ...this.modelData.relations[index], ...patch };
1997
2353
  const ids = new Set(this.modelData.entities.map(entity => entity.id));
1998
- if (!ids.has(next.from) || !ids.has(next.to)) throw new Error("Los extremos de la relación deben existir en el modelo.");
2354
+ if (!ids.has(next.from) || !ids.has(next.to)) throw new Error("Both relation endpoints must exist in the model.");
1999
2355
  this.modelData.relations = this.modelData.relations.map((item, itemIndex) => itemIndex === index ? next : item);
2000
2356
  return this._commitModelChange("relation-update", { index, patch, relation: { ...next } }, options);
2001
2357
  }
2002
2358
 
2003
2359
  removeRelation(reference, options = {}) {
2004
- if (!this.modelData) throw new Error("No hay ningún modelo cargado.");
2360
+ if (!this.modelData) throw new Error("No model is loaded.");
2005
2361
  const index = this._relationIndex(reference);
2006
- if (index < 0 || index >= this.modelData.relations.length) throw new Error("No existe la relación indicada.");
2362
+ if (index < 0 || index >= this.modelData.relations.length) throw new Error("The requested relation does not exist.");
2007
2363
  const [relation] = this.modelData.relations.splice(index, 1);
2008
2364
  if (this.selectedRelationIndex === index) this.selectedRelationIndex = null;
2009
2365
  else if (this.selectedRelationIndex > index) this.selectedRelationIndex -= 1;
@@ -2126,7 +2482,7 @@ class LiquidCarsAtlas extends HTMLElement {
2126
2482
  }
2127
2483
 
2128
2484
  restoreInitialCamera(options = {}) {
2129
- const initial = this.modelData?.render?.camera;
2485
+ const initial = this.modelData?.render?.views?.["3d"]?.camera || this.modelData?.render?.camera;
2130
2486
  if (initial) return this.setCameraView(initial, options);
2131
2487
  this.yaw = .37;
2132
2488
  this.pitch = .22;
@@ -2225,7 +2581,7 @@ class LiquidCarsAtlas extends HTMLElement {
2225
2581
 
2226
2582
  setOpenContainers(ids, options = {}) {
2227
2583
  if (ids == null || typeof ids === "string" || typeof ids[Symbol.iterator] !== "function") {
2228
- throw new TypeError("Los contenedores abiertos deben indicarse mediante un iterable de ids.");
2584
+ throw new TypeError("Open containers must be provided as an iterable of ids.");
2229
2585
  }
2230
2586
  this._replaceOpenContainers(ids, options);
2231
2587
  return this;
@@ -2233,8 +2589,8 @@ class LiquidCarsAtlas extends HTMLElement {
2233
2589
 
2234
2590
  setContainerOpen(id, value = true, options = {}) {
2235
2591
  const entity = this.modelData?.entities?.find(item => item.id === id);
2236
- if (!entity) throw new Error(`No existe una entidad con id "${id}".`);
2237
- if (!isContainerType(entity.type)) throw new TypeError(`La entidad "${id}" no es un contenedor.`);
2592
+ if (!entity) throw new Error(`No entity with id "${id}" exists.`);
2593
+ if (!isContainerType(entity.type)) throw new TypeError(`Entity "${id}" is not a container.`);
2238
2594
  const next = new Set(this.open);
2239
2595
  if (value) next.add(id);
2240
2596
  else {
@@ -2329,6 +2685,7 @@ class LiquidCarsAtlas extends HTMLElement {
2329
2685
  // disappear after a theme/model update.
2330
2686
  toolbar.style.setProperty("display", toolbarVisible ? "flex" : "none", "important");
2331
2687
  }
2688
+ this._syncDocumentToolbar();
2332
2689
  if (panel) { panel.dataset.hidden = String(!panelVisible); panel.hidden = !panelVisible; }
2333
2690
  this._syncPanelToolbar();
2334
2691
  if (hint) { hint.dataset.hidden = String(!hintVisible); hint.hidden = !hintVisible; }
@@ -2341,15 +2698,22 @@ class LiquidCarsAtlas extends HTMLElement {
2341
2698
  const script = this.querySelector('script[type="application/json"]');
2342
2699
  if (!script) return this.load({ palette: DEFAULT_PALETTE, entities: [], relations: [] });
2343
2700
  try { return this.load(JSON.parse(script.textContent)); }
2344
- catch (error) { this._showError("No se pudo leer el modelo JSON: " + error.message); this.dispatchEvent(new CustomEvent("lc-error", { detail: error })); }
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;
@@ -2373,7 +2741,7 @@ class LiquidCarsAtlas extends HTMLElement {
2373
2741
  else if (pendingSelection?.kind === "entity") this.select(pendingSelection.id, pendingSelection.options);
2374
2742
  return this;
2375
2743
  } catch (error) {
2376
- this._showError("No se pudo cargar el modelo: " + error.message);
2744
+ this._showError("Could not load the model: " + error.message);
2377
2745
  this.dispatchEvent(new CustomEvent("lc-error", { detail: error }));
2378
2746
  throw error;
2379
2747
  } finally {
@@ -2392,7 +2760,7 @@ class LiquidCarsAtlas extends HTMLElement {
2392
2760
  this.resize();
2393
2761
  return true;
2394
2762
  } catch (error) {
2395
- this._showError("No se pudo iniciar WebGL. Activa la aceleración gráfica del navegador.");
2763
+ this._showError("WebGL could not start. Enable hardware acceleration in your browser.");
2396
2764
  return false;
2397
2765
  }
2398
2766
  }
@@ -2472,7 +2840,7 @@ class LiquidCarsAtlas extends HTMLElement {
2472
2840
  }
2473
2841
 
2474
2842
  _build(options = {}) {
2475
- if (!this._ensureRenderer()) return;
2843
+ if (this.viewMode !== "2d" && !this._ensureRenderer()) return;
2476
2844
  const selectedId = options.preserveSelection || null;
2477
2845
  const selectedRelationReference = options.preserveRelation ?? null;
2478
2846
  this._clearObjects();
@@ -2496,7 +2864,7 @@ class LiquidCarsAtlas extends HTMLElement {
2496
2864
  node.ports = { ...(definition?.anchors || {}), ...(entity.ports || {}) };
2497
2865
  if (isCustom) {
2498
2866
  try {
2499
- const content = createGeometryContent(definition, entity, this.modelData.palette, () => this.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,22 +3207,24 @@ class LiquidCarsAtlas extends HTMLElement {
2838
3207
 
2839
3208
  _renderPanel() {
2840
3209
  if (!this.modelData) return;
3210
+ this._syncJumpButton();
2841
3211
  const selectedRelation = this.selectedRelationIndex === null ? null : this.relations[this.selectedRelationIndex];
2842
3212
  if (selectedRelation) {
2843
3213
  const mode = relationVisualMode(selectedRelation);
2844
3214
  const relationGroup = (this.modelData.relationGroups || []).find(group => group.id === selectedRelation.group);
2845
3215
  const relationGroups = new Map((this.modelData.relationGroups || []).map(group => [group.id, group]));
2846
3216
  const bundle = this.getRelationBundle(this.selectedRelationIndex);
2847
- const description = selectedRelation.text ? '<p>' + escapeHtml(selectedRelation.text) + '</p>' : '';
3217
+ const description = selectedRelation.content ? '<div class="rich-content" data-rich-content></div>' : selectedRelation.text ? '<p>' + escapeHtml(selectedRelation.text) + '</p>' : '';
2848
3218
  const externalLink = externalLinkMarkup(selectedRelation.url);
2849
3219
  const groupMarkup = relationGroup ? '<h3>GRUPO</h3><p>' + escapeHtml(relationGroup.label || relationGroup.id) + '</p>' : '';
2850
3220
  const bundleMarkup = bundle.map(({ index, relation }) => {
2851
3221
  const group = relationGroups.get(relation.group);
2852
- const metadata = [group?.label || group?.id || "Sin grupo", relationVisualMode(relation)].join(" · ");
3222
+ const metadata = [group?.label || group?.id || "Ungrouped", relationVisualMode(relation)].join(" · ");
2853
3223
  return '<button class="relation' + (index === this.selectedRelationIndex ? ' selected' : '') + '" data-action="relation" data-relation-index="' + index + '"><strong>' + escapeHtml(relation.label || `${relation.from} → ${relation.to}`) + '</strong><small>' + escapeHtml(metadata) + '</small></button>';
2854
3224
  }).join("");
2855
- const connectionLabel = bundle.length === 1 ? "1 conexión" : `${bundle.length} conexiones`;
2856
- this.panel.innerHTML = '<button class="back" data-action="overview">← Vista de conjunto</button><button data-action="focus-selection">◎ Enfocar selección</button><div class="eyebrow">RELACIÓN</div><h2>' + escapeHtml(selectedRelation.label || `${selectedRelation.from} → ${selectedRelation.to}`) + '</h2><div class="sub">' + escapeHtml(selectedRelation.from) + ' → ' + escapeHtml(selectedRelation.to) + '</div>' + description + externalLink + groupMarkup + '<h3>MODO</h3><p>' + escapeHtml(mode) + '</p><h3>TUBO VIRTUAL</h3><p>' + escapeHtml(`${selectedRelation.from} ↔ ${selectedRelation.to} · ${connectionLabel}`) + '</p>' + bundleMarkup;
3225
+ const connectionLabel = bundle.length === 1 ? "1 connection" : `${bundle.length} connections`;
3226
+ this.panel.innerHTML = '<button class="back" data-action="overview">← Overview</button><button data-action="focus-selection">◎ Focus selection</button><div class="eyebrow">RELATION</div><h2>' + escapeHtml(selectedRelation.label || `${selectedRelation.from} → ${selectedRelation.to}`) + '</h2><div class="sub">' + escapeHtml(selectedRelation.from) + ' → ' + escapeHtml(selectedRelation.to) + '</div>' + description + externalLink + groupMarkup + '<h3>MODE</h3><p>' + escapeHtml(mode) + '</p><h3>VIRTUAL TUBE</h3><p>' + escapeHtml(`${selectedRelation.from} ↔ ${selectedRelation.to} · ${connectionLabel}`) + '</p>' + bundleMarkup;
3227
+ this._renderRichContent(selectedRelation.content, selectedRelation.label || `${selectedRelation.from} → ${selectedRelation.to}`);
2857
3228
  this.panel.querySelector('[data-action="overview"]')?.addEventListener("click", () => this.reset());
2858
3229
  this.panel.querySelector('[data-action="focus-selection"]')?.addEventListener("click", () => this.focusSelection());
2859
3230
  this.panel.querySelectorAll('[data-action="relation"]').forEach(button => button.addEventListener("click", () => this.selectRelation(Number(button.dataset.relationIndex), { source: "panel" })));
@@ -2862,7 +3233,7 @@ class LiquidCarsAtlas extends HTMLElement {
2862
3233
  }
2863
3234
  const node = this.selected;
2864
3235
  if (!node) {
2865
- this.panel.innerHTML = '<div class="eyebrow">LIQUIDCARS ATLAS</div><h2>Explora el modelo.</h2><p>Selecciona un volumen para consultar su función y sus relaciones.</p><p class="note">El componente acepta un objeto JSON con palette, entities y relations.</p>';
3236
+ this.panel.innerHTML = '<div class="eyebrow">LIQUIDCARS ATLAS</div><h2>Explore the model.</h2><p>Select an item to inspect its purpose and relations.</p><p class="note">The component accepts a JSON object with palette, entities and relations.</p>';
2866
3237
  this._applyTheme();
2867
3238
  return;
2868
3239
  }
@@ -2876,13 +3247,14 @@ class LiquidCarsAtlas extends HTMLElement {
2876
3247
  path.unshift(cursor);
2877
3248
  cursor = cursor.parent ? this.nodes.get(cursor.parent) : null;
2878
3249
  }
2879
- const pathMarkup = '<nav class="crumbs" aria-label="Ruta del modelo"><button data-action="overview">Modelo</button>' + path.map((item, index) => '<span>/</span><button data-action="path" data-id="' + escapeHtml(item.id) + '" class="' + (index === path.length - 1 ? 'current' : '') + '">' + escapeHtml(item.name) + '</button>').join("") + '</nav>';
3250
+ const pathMarkup = '<nav class="crumbs" aria-label="Model path"><button data-action="overview">Model</button>' + path.map((item, index) => '<span>/</span><button data-action="path" data-id="' + escapeHtml(item.id) + '" class="' + (index === path.length - 1 ? 'current' : '') + '">' + escapeHtml(item.name) + '</button>').join("") + '</nav>';
2880
3251
  const relationMarkup = relations.map(relation => {
2881
3252
  const index = this.relations.indexOf(relation);
2882
3253
  const description = relation.text ? '<small>' + escapeHtml(relation.text) + '</small>' : '';
2883
3254
  return '<button class="relation" data-action="relation" data-relation-index="' + index + '"><strong>' + escapeHtml(relation.label || (relation.from === node.id ? relation.to : relation.from)) + '</strong><small>' + escapeHtml(relation.from) + ' → ' + escapeHtml(relation.to) + '</small>' + description + '</button>';
2884
3255
  }).join("");
2885
- this.panel.innerHTML = '<button class="back" data-action="overview">← Vista de conjunto</button>' + pathMarkup + '<button data-action="focus-selection">◎ Enfocar selección</button><div class="eyebrow">' + escapeHtml(isContainerType(node.type) ? "CONTENEDOR" : node.type === "actor" ? "ACTOR EXTERNO" : "COMPONENTE") + '</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) ? "Cerrar bloque" : "Abrir bloque") + '</button>' : "") + (children.length ? '<h3>CONTIENE</h3><p>' + children.length + ' pieza(s) interna(s).</p>' : "") + '<h3>RELACIONES</h3>' + (relationMarkup || '<p>No hay relaciones directas.</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
 
@@ -3234,10 +3615,10 @@ class LiquidCarsAtlas extends HTMLElement {
3234
3615
 
3235
3616
  async _copyFromToolbar(background, control) {
3236
3617
  if (control) control.disabled = true;
3237
- this._setCopyToolbarStatus("Copiando…");
3618
+ this._setCopyToolbarStatus("Copying…");
3238
3619
  try {
3239
3620
  const result = await this.copyFrame({ background, scale: 2, fallback: "download", source: "toolbar" });
3240
- this._setCopyToolbarStatus(result.method === "clipboard" ? "Copiado" : "Descargado", "success");
3621
+ this._setCopyToolbarStatus(result.method === "clipboard" ? "Copied" : "Downloaded", "success");
3241
3622
  return result;
3242
3623
  } catch (error) {
3243
3624
  this._setCopyToolbarStatus("Error", "error");
@@ -3249,6 +3630,38 @@ class LiquidCarsAtlas extends HTMLElement {
3249
3630
  }
3250
3631
 
3251
3632
  _bindControls() {
3633
+ for (const action of ["atlas-back", "atlas-forward"]) {
3634
+ const button = this.navigationToolbar?.querySelector(`[data-action="${action}"]`);
3635
+ if (!button) continue;
3636
+ let origin = null;
3637
+ const cancel = () => { if (this._historyPressTimer) clearTimeout(this._historyPressTimer); this._historyPressTimer = null; };
3638
+ button.addEventListener("pointerdown", event => {
3639
+ if (button.disabled || event.button !== 0) return;
3640
+ origin = { x: event.clientX, y: event.clientY };
3641
+ cancel();
3642
+ this._historyPressTimer = setTimeout(() => {
3643
+ this._historyPressTimer = null;
3644
+ this._historyLongPress = action;
3645
+ this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
3646
+ }, 480);
3647
+ });
3648
+ button.addEventListener("pointermove", event => {
3649
+ if (origin && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 7) cancel();
3650
+ });
3651
+ button.addEventListener("pointerup", () => { origin = null; cancel(); setTimeout(() => { this._historyLongPress = null; }, 100); });
3652
+ button.addEventListener("pointercancel", cancel);
3653
+ button.addEventListener("pointerleave", cancel);
3654
+ button.addEventListener("contextmenu", event => {
3655
+ event.preventDefault();
3656
+ if (!button.disabled) this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
3657
+ });
3658
+ button.addEventListener("keydown", event => {
3659
+ if (event.key !== "ArrowDown" || button.disabled) return;
3660
+ event.preventDefault();
3661
+ this._showHistoryPopover(action === "atlas-back" ? "back" : "forward");
3662
+ this.historyPopover?.querySelector("button")?.focus();
3663
+ });
3664
+ }
3252
3665
  this.toolbar?.querySelectorAll(".toolbar-menu").forEach(menu => menu.addEventListener("toggle", () => {
3253
3666
  if (menu.open) this._closeToolbarMenus(menu);
3254
3667
  }));
@@ -3256,17 +3669,33 @@ class LiquidCarsAtlas extends HTMLElement {
3256
3669
  // Safari may leave relatedTarget null because pointer-clicked buttons
3257
3670
  // do not necessarily receive focus. Only a known destination outside
3258
3671
  // the toolbar may close the <details> menu before click is dispatched.
3259
- if (focusMovesOutside(this.toolbar, event)) this._closeToolbarMenus();
3672
+ if (focusMovesOutside(this.toolbar, event)) { this._closeToolbarMenus(); this._closeHistoryPopover(); }
3673
+ });
3674
+ this.navigationToolbar?.addEventListener("focusout", event => {
3675
+ if (focusMovesOutside(this.navigationToolbar, event)) this._closeHistoryPopover();
3260
3676
  });
3261
3677
  this.shadowRoot.addEventListener("keydown", event => {
3262
3678
  if (event.key !== "Escape") return;
3679
+ this._closeHistoryPopover();
3263
3680
  this._closeToolbarMenus();
3264
3681
  this.canvas.focus({ preventScroll: true });
3265
3682
  });
3266
3683
  this.shadowRoot.addEventListener("click", event => {
3267
3684
  const actionControl = closestEventElement(event, "[data-action]");
3268
3685
  const action = actionControl?.dataset.action;
3686
+ if (this._historyLongPress === action) { this._historyLongPress = null; return; }
3687
+ if (!closestEventElement(event, ".atlas-history-popover")) this._closeHistoryPopover();
3269
3688
  if (!closestEventElement(event, ".toolbar-menu")) this._closeToolbarMenus();
3689
+ if (action === "atlas-home") void this.goHome();
3690
+ if (action === "atlas-back") void this.goBack();
3691
+ if (action === "atlas-forward") void this.goForward();
3692
+ if (action === "atlas-history-visit") { this._closeHistoryPopover(); void this._restoreVisit(Number(actionControl.dataset.visitIndex)); }
3693
+ if (action === "atlas-jump") void this.openAtlas(actionControl.dataset.atlasRef).catch(() => {});
3694
+ if (action === "view-mode") this.setViewMode(this.viewMode === "2d" ? "3d" : "2d", { source: "toolbar" });
3695
+ if (this.viewMode === "2d" && actionControl?.closest?.(".toolbar") && this.diagramView.action(action, actionControl)) {
3696
+ if (actionControl?.closest?.(".toolbar-popover") && action !== "relation-group-toggle") this._closeToolbarMenus();
3697
+ return;
3698
+ }
3270
3699
  if (action === "minus") this.zoomOut();
3271
3700
  if (action === "plus") this.zoomIn();
3272
3701
  if (action === "fit") this.fit();