@liquidcars/atlas 0.1.52 → 0.2.0

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,10 +1,15 @@
1
1
  import { validateRuntimeModel, canonicalRelation } from "./model.js";
2
2
  import * as THREE from "./three.module.js";
3
3
  import atlasPackage from "../package.json" with { type: "json" };
4
- import { renderDiagramSvg, diagramSvgToPng } from "./diagram-svg.js";
4
+ import { renderDiagramSvg, diagramSvgToPng, computeDiagramLayout } from "./diagram-svg.js";
5
5
  import { DiagramView, DIAGRAM_VIEW_CSS, DIAGRAM_TOOLBAR } from "./diagram-view.js";
6
6
 
7
- export { renderDiagramSvg, diagramSvgToPng };
7
+ export { renderDiagramSvg, diagramSvgToPng, computeDiagramLayout };
8
+ import {installPlanarAtlas} from './diagram-webgl.js';
9
+ export {orthographicFit, diagramViewForModel} from './diagram-webgl.js';
10
+ export {DiagramScene} from './diagram-scene.js';
11
+ export const OrthographicCamera = THREE.OrthographicCamera;
12
+ export const DiagramGraphics = Object.fromEntries(['Group','BoxGeometry','Vector3','Color','BufferGeometry','Float32BufferAttribute','Mesh','MeshBasicMaterial','LineSegments','LineBasicMaterial','PlaneGeometry','CanvasTexture'].map(name => [name, THREE[name]]));
8
13
  import { resolveAtlasReference, atlasReferenceTarget } from "./document-navigation.js";
9
14
  export { resolveAtlasReference, atlasReferenceTarget } from "./document-navigation.js";
10
15
  import { renderPanelContent } from "./panel-content.js";
@@ -355,7 +360,7 @@ const CSS = [
355
360
  "*{box-sizing:border-box}.atlas{display:grid;grid-template-columns:minmax(0,1fr) 300px;height:100%}.atlas[data-panel=hidden]{grid-template-columns:1fr}.viewport{position:relative;min-width:0;overflow:hidden;background:radial-gradient(ellipse at 50% 45%,var(--lc-bg-glow,#1c2b39),var(--lc-bg-fade,#0a111a) 82%)}canvas{display:block;width:100%;height:100%;cursor:grab;touch-action:none}.toolbar{position:absolute;z-index:10;bottom:28px;left:50%;transform:translateX(-50%);display:flex;gap:3px;padding:6px;background:var(--lc-toolbar-bg,#15232fe8);border:1px solid var(--lc-toolbar-border,#3a5062);border-radius:8px;white-space:nowrap}.toolbar[data-hidden=true],.toolbar[hidden],.panel[data-hidden=true],.panel[hidden],.crumbs[data-hidden=true],.crumbs[hidden],.hint[data-hidden=true],.hint[hidden]{display:none!important}.toolbar button{height:30px;padding:0 9px;border:0;border-radius:5px;background:none;color:var(--lc-toolbar-text,#dbe7ec);cursor:pointer}.toolbar button:hover,.toolbar button.active{background:var(--lc-toolbar-active,#1f3d4b);color:var(--lc-toolbar-active-text,#c5f2e9)}.toolbar i{display:block;width:1px;margin:2px 3px;background:#38515f}.hint{position:absolute;z-index:9;top:8px;bottom:auto;left:0;right:0;text-align:center;padding:2px 8px;font-size:11px;line-height:16px;color:var(--lc-hint,#8da6b8);pointer-events:none}.panel{overflow:auto;padding:24px 20px;background:var(--lc-panel-bg,linear-gradient(140deg,#182633,#0e1925 65%));border-left:1px solid var(--lc-panel-border,#2a3a48)}.panel h2{font-size:26px;line-height:1.12;font-weight:470;margin:12px 0;color:var(--lc-panel-title,#e9f0f5)}.panel h3{font-size:11px;letter-spacing:1.4px;color:#91aabd;margin:24px 0 10px}.panel p{font-size:14px;line-height:1.55;color:var(--lc-panel-text,#b5c6d2)}.panel .eyebrow{font-size:11px;letter-spacing:1.7px;color:#6ad7c0}.panel .sub{font-size:13px;color:#8fa8bd;margin-bottom:16px}.panel .external-link{display:inline-flex;align-items:center;gap:6px;margin:2px 0 12px;color:#6ad7c0;font-size:13px;font-weight:600;text-decoration:none}.panel .external-link:hover{text-decoration:underline}.panel button{display:block;width:100%;padding:10px 0;text-align:left;background:none;border:0;border-bottom:1px solid var(--lc-panel-border,#2a3a48);color:#d9e7eb;cursor:pointer}.panel button:hover{color:#6ad7c0}.panel .relation{padding:10px 0;border-bottom:1px solid var(--lc-panel-border,#2a3a48)}.panel .relation.selected{padding-left:9px;border-left:2px solid #6ad7c0;background:#18343f}.panel .relation strong{display:block;font-weight:550}.panel .relation small{display:block;color:#86a6b9;font-size:12px;margin-top:4px}.panel .back{color:#9db7c8;font-size:13px}.panel .crumbs{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin:0 0 12px}.panel .crumbs button{display:inline;width:auto;padding:3px 7px;border:0;border-radius:4px;background:var(--lc-crumb-bg,#142733);color:var(--lc-crumb-text,#91b6c7);font-size:12px}.panel .crumbs button.current{background:var(--lc-crumb-active,#1c4a4d);color:var(--lc-crumb-active-text,#c5f2e9)}.panel .note{border-top:1px solid var(--lc-panel-border,#2a3a48);padding-top:16px;margin-top:22px;font-size:12px;color:#7692a6}.error{position:absolute;inset:20%;padding:18px;background:#172b3b;border:1px solid #7896a9;line-height:1.5;z-index:4}@media(max-width:900px){.toolbar{gap:0}.toolbar button{padding:0 6px;font-size:12px}}@media(max-width:760px){:host{height:680px}.atlas{grid-template-columns:1fr;grid-template-rows:minmax(420px,1fr) auto}.panel{max-height:250px;border-left:0;border-top:1px solid var(--lc-panel-border,#2a3a48);padding:18px}.toolbar{bottom:24px}.hint{top:8px;bottom:auto;display:block}}"
356
361
  ].join("");
357
362
 
358
- 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}}";
363
+ 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{max-height:min(420px,calc(100dvh - 160px),var(--lc-menu-height,420px));overflow-y:auto;overscroll-behavior:contain;max-width:calc(100vw - 32px);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}}";
359
364
 
360
365
  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}";
361
366
  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}}";
@@ -1458,7 +1463,7 @@ function makeLabel(text, color, dimensions, type = "", outlined = true) {
1458
1463
  const context = bitmap.getContext("2d");
1459
1464
  const width = dimensions.x * .88;
1460
1465
  const height = Math.min(.82, dimensions.y * .64);
1461
- const density = 320;
1466
+ const density = Math.min(320, 4096 / Math.max(width, height));
1462
1467
  bitmap.width = Math.ceil(width * density);
1463
1468
  bitmap.height = Math.ceil(height * density);
1464
1469
  context.imageSmoothingEnabled = true;
@@ -1828,6 +1833,7 @@ class LiquidCarsAtlas extends HTMLElement {
1828
1833
  globalThis.removeEventListener?.("afterprint", this._afterPrintListener);
1829
1834
  if (typeof this._printMedia?.removeEventListener === "function") this._printMedia.removeEventListener("change", this._printMediaListener);
1830
1835
  else this._printMedia?.removeListener?.(this._printMediaListener);
1836
+ this.dispatchEvent(new CustomEvent("lc-disconnect"));
1831
1837
  this.restoreAfterPrint();
1832
1838
  if (this._copyStatusTimer) globalThis.clearTimeout?.(this._copyStatusTimer);
1833
1839
  if (this._historyPressTimer) globalThis.clearTimeout?.(this._historyPressTimer);
@@ -2807,7 +2813,7 @@ class LiquidCarsAtlas extends HTMLElement {
2807
2813
  }
2808
2814
 
2809
2815
  zoomOut() {
2810
- this.targetZoom = Math.max(MIN_ZOOM, this.targetZoom / 1.18);
2816
+ this.targetZoom = Math.max(this._webglPlanar ? .001 : MIN_ZOOM, this.targetZoom / 1.18);
2811
2817
  this.invalidate();
2812
2818
  return this;
2813
2819
  }
@@ -2824,7 +2830,7 @@ class LiquidCarsAtlas extends HTMLElement {
2824
2830
  let zoom = 1.22;
2825
2831
  if (this.selected) {
2826
2832
  target = this._worldPosition(this.selected);
2827
- zoom = this.selected.parent ? 1.35 : 1.1;
2833
+ zoom = isContainerType(this.selected.type) ? 1.35 : 3.5;
2828
2834
  } else if (this.selectedRelationIndex !== null) {
2829
2835
  const relation = this.relations[this.selectedRelationIndex];
2830
2836
  const from = relation && this.nodes.get(relation.from);
@@ -3365,7 +3371,7 @@ class LiquidCarsAtlas extends HTMLElement {
3365
3371
  node.edge.scale.fromArray(baked ? [1, 1, 1] : entity.s);
3366
3372
  node.group.add(node.edge);
3367
3373
  }
3368
- node.label = makeLabel(entity.name, color, node.labelSize, entity.type, this.showShellLabels);
3374
+ node.label = makeLabel(this._webglPlanar ? "" : entity.name, color, this._webglPlanar ? new THREE.Vector3(1, .1, 1) : node.labelSize, entity.type, this.showShellLabels);
3369
3375
  node.labelAnchor = resolveEntityLabelAnchor(entity, this.modelData.style);
3370
3376
  node.label.userData.node = node;
3371
3377
  node.label.traverse(labelFace => { labelFace.userData.node = node; });
@@ -3832,7 +3838,7 @@ class LiquidCarsAtlas extends HTMLElement {
3832
3838
  }
3833
3839
 
3834
3840
  invalidate() {
3835
- if (this.viewMode === "3d" && !this.animationFrame && this.renderer) this.animationFrame = requestAnimationFrame(time => this._renderFrame(time));
3841
+ if ((this.viewMode === "3d" || this._webglPlanar) && !this.animationFrame && this.renderer) this.animationFrame = requestAnimationFrame(time => this._renderFrame(time));
3836
3842
  }
3837
3843
 
3838
3844
  _geometryContentUpdated() {
@@ -3844,7 +3850,7 @@ class LiquidCarsAtlas extends HTMLElement {
3844
3850
 
3845
3851
  _renderFrame(time) {
3846
3852
  this.animationFrame = 0;
3847
- if (!this.renderer || this.viewMode !== "3d") return;
3853
+ if (!this.renderer || (this.viewMode !== "3d" && !this._webglPlanar)) return;
3848
3854
  const zoomGap = Math.abs(this.targetZoom - this.zoom);
3849
3855
  const focusGap = this.targetFocus.distanceTo(this.focus);
3850
3856
  this.zoom += (this.targetZoom - this.zoom) * .12;
@@ -3852,12 +3858,12 @@ class LiquidCarsAtlas extends HTMLElement {
3852
3858
  const distance = this._baseCameraDistance() / this.zoom;
3853
3859
  this.cameraTarget.set(this.focus.x, this.focus.y, this.focus.z);
3854
3860
  this.cameraPosition.set(Math.sin(this.yaw) * Math.cos(this.pitch), Math.sin(this.pitch), Math.cos(this.yaw) * Math.cos(this.pitch)).multiplyScalar(distance).add(this.cameraTarget);
3855
- this.camera.position.copy(this.cameraPosition);
3856
- this.camera.lookAt(this.cameraTarget);
3861
+ if (this._webglPlanar) this._updatePlanarCamera();
3862
+ else { this.camera.position.copy(this.cameraPosition); this.camera.lookAt(this.cameraTarget); }
3857
3863
  // Camera-facing is a presentation transform only. Positions, container
3858
3864
  // shells and relation routing remain in model space while leaf visuals
3859
3865
  // inherit the camera orientation around their unchanged centres.
3860
- this._applyItemFacing();
3866
+ if (!this._webglPlanar) this._applyItemFacing();
3861
3867
  const focused = this._focusedEntityIds();
3862
3868
  for (const node of this.nodes.values()) {
3863
3869
  const focusAlpha = this._focusAlpha(node, focused);
@@ -4002,9 +4008,10 @@ class LiquidCarsAtlas extends HTMLElement {
4002
4008
  child.position.lerpVectors(child.userData.start, child.userData.end, factor);
4003
4009
  }
4004
4010
  }
4011
+ if (this._webglPlanar) this._applyDiagramPresentation();
4005
4012
  this.renderer.render(this.scene, this.camera);
4006
4013
  this._positionJumpButton();
4007
- if (zoomGap > .001 || focusGap > .001 || this.relationGroup.children.length || this.drag) this.invalidate();
4014
+ if (zoomGap > .001 || focusGap > .001 || (!this._webglPlanar && this.relationGroup.children.length) || this.drag) this.invalidate();
4008
4015
  }
4009
4016
 
4010
4017
  _pick(event) {
@@ -4124,7 +4131,7 @@ class LiquidCarsAtlas extends HTMLElement {
4124
4131
  });
4125
4132
  this.shadowRoot.addEventListener("click", event => {
4126
4133
  const actionControl = closestEventElement(event, "[data-action]");
4127
- const action = actionControl?.dataset.action;
4134
+ const action = actionControl?.dataset?.action;
4128
4135
  if (this._historyLongPress === action) { this._historyLongPress = null; return; }
4129
4136
  if (!closestEventElement(event, ".atlas-history-popover")) this._closeHistoryPopover();
4130
4137
  if (!closestEventElement(event, ".toolbar-menu")) this._closeToolbarMenus();
@@ -4187,7 +4194,7 @@ class LiquidCarsAtlas extends HTMLElement {
4187
4194
  if (this.drag.mode !== "touch-pan") this.drag = { mode: "touch-pan", x, y, distance, moved: true };
4188
4195
  else {
4189
4196
  this._panByPixels(x - this.drag.x, y - this.drag.y);
4190
- this.targetZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, this.targetZoom * distance / Math.max(1, this.drag.distance)));
4197
+ this.targetZoom = Math.min(MAX_ZOOM, Math.max(this._webglPlanar ? .001 : MIN_ZOOM, this.targetZoom * distance / Math.max(1, this.drag.distance)));
4191
4198
  Object.assign(this.drag, { x, y, distance, moved: true });
4192
4199
  }
4193
4200
  this.invalidate();
@@ -4199,7 +4206,7 @@ class LiquidCarsAtlas extends HTMLElement {
4199
4206
  if (Math.abs(dx) + Math.abs(dy) > 3) this.drag.moved = true;
4200
4207
  this.drag.x = event.clientX;
4201
4208
  this.drag.y = event.clientY;
4202
- if (this.drag.mode === "pan" || event.shiftKey) this._panByPixels(dx, dy);
4209
+ if (this._webglPlanar || this.drag.mode === "pan" || event.shiftKey) this._panByPixels(dx, dy);
4203
4210
  else {
4204
4211
  this.yaw -= dx * .005;
4205
4212
  this.pitch = Math.max(-1.56, Math.min(1.56, this.pitch + dy * .005));
@@ -4239,7 +4246,7 @@ class LiquidCarsAtlas extends HTMLElement {
4239
4246
  if (picked?.kind === "entity") this.select(picked.node.id, { source: "double-click", navigation: "fit" });
4240
4247
  if (picked?.kind === "relation") this.selectRelation(picked.index, { source: "double-click", navigation: "fit" });
4241
4248
  });
4242
- this.canvas.addEventListener("wheel", event => { event.preventDefault(); this.targetZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, this.targetZoom * Math.exp(-event.deltaY * .001))); this.invalidate(); }, { passive: false });
4249
+ this.canvas.addEventListener("wheel", event => { event.preventDefault(); if (this._webglPlanar) this._diagramFitted = false; this.targetZoom = Math.min(MAX_ZOOM, Math.max(this._webglPlanar ? .001 : MIN_ZOOM, this.targetZoom * Math.exp(-event.deltaY * .001))); this.invalidate(); }, { passive: false });
4243
4250
  }
4244
4251
 
4245
4252
  _showError(message) {
@@ -4250,5 +4257,7 @@ class LiquidCarsAtlas extends HTMLElement {
4250
4257
  }
4251
4258
  }
4252
4259
 
4260
+ installPlanarAtlas(LiquidCarsAtlas, OrthographicCamera, computeDiagramLayout, DiagramGraphics);
4261
+
4253
4262
  if (!customElements.get("liquidcars-atlas")) customElements.define("liquidcars-atlas", LiquidCarsAtlas);
4254
4263
  export { LiquidCarsAtlas, normalizeModel };
package/src/model.js CHANGED
@@ -7,6 +7,120 @@ export const modelRepresentation = model => isRuntimeModel(model) ? "native" : "
7
7
  const object = value => value !== null && typeof value === "object" && !Array.isArray(value);
8
8
  const modes = new Set(["directed", "bidirectional", "broken"]);
9
9
  const aliases = { "two-way": "bidirectional", both: "bidirectional", interrupted: "broken" };
10
+ export const CUSTOM_ATTRIBUTE_TYPES = Object.freeze(["number", "text", "long-text", "select", "multi-select"]);
11
+ export const CUSTOM_ATTRIBUTE_SCOPES = Object.freeze(["container", "entity", "relation"]);
12
+ export const MODEL_LIBRARY_SOURCE = "npm";
13
+ const customAttributeTypes = new Set(CUSTOM_ATTRIBUTE_TYPES);
14
+ const customAttributeScopes = new Set(CUSTOM_ATTRIBUTE_SCOPES);
15
+ const npmPackagePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/i;
16
+ const exactVersionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
17
+ const geometryNamespacePattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/i;
18
+
19
+ /** Validate author geometry packs loaded explicitly for this model. */
20
+ export function validateModelLibraries(model) {
21
+ if (!object(model)) throw new Error("Atlas model must be an object.");
22
+ if (model.libraries === undefined) return model;
23
+ if (!Array.isArray(model.libraries)) throw new Error("Model libraries must be an array.");
24
+ const packages = new Set(), namespaces = new Set();
25
+ for (const library of model.libraries) {
26
+ if (!object(library)) throw new Error("Every model library needs a definition.");
27
+ const source = library.source || MODEL_LIBRARY_SOURCE;
28
+ if (source !== MODEL_LIBRARY_SOURCE) throw new Error(`Unsupported model library source: ${source}.`);
29
+ if (typeof library.package !== "string" || !npmPackagePattern.test(library.package)) {
30
+ throw new Error("Model libraries require a valid npm package name.");
31
+ }
32
+ if (packages.has(library.package)) throw new Error(`Duplicate model library package: ${library.package}.`);
33
+ packages.add(library.package);
34
+ if (typeof library.version !== "string" || !exactVersionPattern.test(library.version)) {
35
+ throw new Error(`Model library ${library.package} requires an exact semantic version.`);
36
+ }
37
+ if (library.namespace !== undefined) {
38
+ if (typeof library.namespace !== "string" || !geometryNamespacePattern.test(library.namespace)) {
39
+ throw new Error(`Model library ${library.package} has an invalid geometry namespace.`);
40
+ }
41
+ if (namespaces.has(library.namespace)) throw new Error(`Duplicate model library namespace: ${library.namespace}.`);
42
+ namespaces.add(library.namespace);
43
+ }
44
+ }
45
+ return model;
46
+ }
47
+
48
+ function customAttributeTargetScope(target, kind) {
49
+ if (kind === "relation") return "relation";
50
+ return ["container", "shell"].includes(target?.type) ? "container" : "entity";
51
+ }
52
+
53
+ /**
54
+ * Validates the optional model-level custom attribute schema and every value
55
+ * stored on containers, entities and relations. This is deliberately shared
56
+ * by native and declarative models so tools can rely on one public contract.
57
+ */
58
+ export function validateCustomAttributes(model) {
59
+ if (!object(model)) throw new Error("Atlas model must be an object.");
60
+ if (model.customAttributes === undefined) return model;
61
+ if (!Array.isArray(model.customAttributes)) throw new Error("Custom attributes must be an array.");
62
+ const definitions = new Map();
63
+ for (const definition of model.customAttributes) {
64
+ if (!object(definition)) throw new Error("Every custom attribute needs a definition.");
65
+ if (typeof definition.id !== "string" || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(definition.id)) {
66
+ throw new Error("Custom attribute IDs must start with a letter and use letters, numbers, _ or -.");
67
+ }
68
+ if (definitions.has(definition.id)) throw new Error(`Duplicate custom attribute ID: ${definition.id}.`);
69
+ if (typeof definition.label !== "string" || !definition.label.trim()) throw new Error(`Custom attribute ${definition.id} needs a label.`);
70
+ if (!customAttributeTypes.has(definition.type)) throw new Error(`Invalid custom attribute type: ${definition.id}.`);
71
+ if (!Array.isArray(definition.scope) || !definition.scope.length || definition.scope.some(scope => !customAttributeScopes.has(scope))) {
72
+ throw new Error(`Invalid custom attribute scope: ${definition.id}.`);
73
+ }
74
+ if (new Set(definition.scope).size !== definition.scope.length) throw new Error(`Duplicate custom attribute scope: ${definition.id}.`);
75
+ if (definition.type === "text" && (!Number.isInteger(definition.maxLength) || definition.maxLength < 1)) {
76
+ throw new Error(`Bounded text attribute ${definition.id} needs a positive maxLength.`);
77
+ }
78
+ if (["select", "multi-select"].includes(definition.type) && (
79
+ !Array.isArray(definition.options) || !definition.options.length ||
80
+ definition.options.some(option => typeof option !== "string" || !option.trim()) ||
81
+ new Set(definition.options).size !== definition.options.length
82
+ )) throw new Error(`Selection attribute ${definition.id} needs unique, non-empty options.`);
83
+ for (const key of ["min", "max", "step"]) if (definition[key] !== undefined && !Number.isFinite(definition[key])) {
84
+ throw new Error(`Invalid ${key} for custom attribute ${definition.id}.`);
85
+ }
86
+ if (definition.step !== undefined && definition.step <= 0) throw new Error(`Custom attribute ${definition.id} needs a positive step.`);
87
+ if (definition.min !== undefined && definition.max !== undefined && definition.min > definition.max) {
88
+ throw new Error(`Custom attribute ${definition.id} has min greater than max.`);
89
+ }
90
+ definitions.set(definition.id, definition);
91
+ }
92
+
93
+ const validateTarget = (target, kind, label) => {
94
+ if (target?.attributes === undefined) return;
95
+ if (!object(target.attributes)) throw new Error(`${label} custom attributes must be an object.`);
96
+ const targetScope = customAttributeTargetScope(target, kind);
97
+ for (const [id, value] of Object.entries(target.attributes)) {
98
+ const definition = definitions.get(id);
99
+ if (!definition) throw new Error(`${label} uses undefined custom attribute: ${id}.`);
100
+ if (!definition.scope.includes(targetScope)) throw new Error(`Custom attribute ${id} does not apply to ${label}.`);
101
+ if (definition.type === "number" && !Number.isFinite(value)) throw new Error(`Custom attribute ${id} on ${label} must be numeric.`);
102
+ if (["text", "long-text", "select"].includes(definition.type) && typeof value !== "string") throw new Error(`Custom attribute ${id} on ${label} must be text.`);
103
+ if (definition.type === "text" && value.length > definition.maxLength) throw new Error(`Custom attribute ${id} on ${label} exceeds maxLength.`);
104
+ if (definition.type === "select" && !definition.options.includes(value)) throw new Error(`Custom attribute ${id} on ${label} is not an allowed option.`);
105
+ if (definition.type === "multi-select" && (
106
+ !Array.isArray(value) || new Set(value).size !== value.length ||
107
+ value.some(option => typeof option !== "string" || !definition.options.includes(option))
108
+ )) throw new Error(`Custom attribute ${id} on ${label} must contain unique allowed options.`);
109
+ if (definition.type === "number" && definition.min !== undefined && value < definition.min) throw new Error(`Custom attribute ${id} on ${label} is below min.`);
110
+ if (definition.type === "number" && definition.max !== undefined && value > definition.max) throw new Error(`Custom attribute ${id} on ${label} is above max.`);
111
+ }
112
+ };
113
+ const visitEntities = items => {
114
+ for (const entity of Array.isArray(items) ? items : []) {
115
+ validateTarget(entity, "entity", `entity ${entity?.id || "<unknown>"}`);
116
+ for (const relation of entity?.relations || []) validateTarget(relation, "relation", `relation ${relation?.id || "<unknown>"}`);
117
+ visitEntities(entity?.children);
118
+ }
119
+ };
120
+ visitEntities(model.entities);
121
+ for (const relation of model.relations || []) validateTarget(relation, "relation", `relation ${relation?.id || "<unknown>"}`);
122
+ return model;
123
+ }
10
124
 
11
125
  export function canonicalRelation(relation) {
12
126
  const value = Array.isArray(relation)
@@ -22,6 +136,8 @@ export function canonicalRelation(relation) {
22
136
 
23
137
  export function validateRuntimeModel(model) {
24
138
  if (!object(model) || !Array.isArray(model.entities)) throw new Error("Atlas JSON needs an entities array.");
139
+ validateModelLibraries(model);
140
+ validateCustomAttributes(model);
25
141
  if (model.relations !== undefined && !Array.isArray(model.relations)) throw new Error("Atlas JSON relations must be an array.");
26
142
  const ids = new Map();
27
143
  for (const entity of model.entities) {