@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.
- package/README.md +7 -0
- package/dist/liquidcars-atlas.js +10752 -4874
- package/dist/liquidcars-atlas.js.map +1 -1
- package/package.json +1 -1
- package/src/diagram-scene.js +125 -0
- package/src/diagram-svg.js +73 -90
- package/src/diagram-view.js +6 -1
- package/src/diagram-webgl.js +166 -0
- package/src/liquidcars-atlas.js +26 -17
- package/src/model.js +116 -0
package/src/liquidcars-atlas.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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) {
|