@liquidcars/atlas 0.1.49 → 0.1.50

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
4
4
  "description": "Reusable 3D conceptual atlas Web Component for LiquidCars-style models",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -539,7 +539,7 @@ export function renderDiagramSvg(atlas, options = {}) {
539
539
  if (hiddenByFocus(box.node.id)) return "";
540
540
  const baseColor = box.node.material?.color || new THREE.Color("#58788d");
541
541
  const color = `#${baseColor.getHexString()}`;
542
- const ink = `#${baseColor.clone().lerp(new THREE.Color("#254253"), .48).getHexString()}`;
542
+ const ink = `#${baseColor.clone().lerp(new THREE.Color(options.containerInk || "#254253"), options.containerInk ? .65 : .48).getHexString()}`;
543
543
  let parent = box.node.parent, guard = 0;
544
544
  while (parent && !boxBackdrops.has(parent) && guard++ < atlas.nodes.size) parent = atlas.nodes.get(parent)?.parent;
545
545
  const parentBackdrop = boxBackdrops.get(parent) || canvasColor;
@@ -1,4 +1,5 @@
1
1
  /** The document's 2D presentation. The SVG is also the source of SVG/PNG exports. */
2
+ import * as THREE from "./three.module.js";
2
3
  const escapeHtml = value => String(value ?? "").replace(/[&<>"']/g, char => ({
3
4
  "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
4
5
  })[char]);
@@ -6,7 +7,8 @@ const escapeHtml = value => String(value ?? "").replace(/[&<>"']/g, char => ({
6
7
  const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
7
8
 
8
9
  export const DIAGRAM_VIEW_CSS = `
9
- .diagram-view{position:absolute;inset:0;overflow:auto;background-color:#f5f5f5;background-image:radial-gradient(circle,#b3b3b3 .8px,transparent 1px);background-size:16px 16px;color:#172c3a;cursor:grab;touch-action:none}
10
+ .toolbar button[hidden],.toolbar-menu[hidden]{display:none!important}
11
+ .diagram-view{position:absolute;inset:0;overflow:auto;background-color:var(--lc-diagram-background,#f5f5f5);background-image:radial-gradient(circle,var(--lc-diagram-dot,#b3b3b3) .8px,transparent 1px);background-size:16px 16px;color:#172c3a;cursor:grab;touch-action:none}
10
12
  .diagram-view[hidden],:host(:not([data-view-mode="2d"])) .diagram-view{display:none!important}
11
13
  :host([data-view-mode="2d"]) .viewport>canvas,:host([data-view-mode="2d"]) .atlas-jump{visibility:hidden;pointer-events:none}
12
14
  :host([data-view-mode="2d"]) .toolbar-group[data-toolbar-group="camera"],
@@ -37,7 +39,7 @@ export const DIAGRAM_VIEW_CSS = `
37
39
  .diagram-orientation-cube circle{fill:#132b3b;stroke:#50758b}
38
40
  .diagram-toolbar .diagram-orbit,.diagram-toolbar .diagram-camera-zoom{display:block;padding:6px 9px;color:#dbe7ec;font-size:12px}
39
41
  .diagram-toolbar .diagram-orbit select,.diagram-toolbar .diagram-camera-zoom input{display:block;width:100%;margin-top:4px}
40
- :host([data-view-mode="2d"]) .hint{color:#527286}
42
+ :host([data-view-mode="2d"]) .hint{color:var(--lc-diagram-hint,#527286)}
41
43
  @media print{:host([data-view-mode="2d"]) .diagram-view{display:block!important;overflow:hidden}.diagram-orientation-cube{display:none!important}}
42
44
  `;
43
45
 
@@ -56,6 +58,7 @@ export const DIAGRAM_TOOLBAR = `<div class="toolbar-group diagram-toolbar" data-
56
58
  const DEFAULT_VIEW = Object.freeze({
57
59
  cameraView: { position: [0, 0, 30], target: [0, 0, 0] },
58
60
  cameraZoom: 1, gap: 20, labels: true, relations: "all", showOrientationCube: true,
61
+ theme: "auto", grid: true,
59
62
  focus: { mode: "all", effect: "dim", layout: "preserve", opacity: .2 }
60
63
  });
61
64
 
@@ -69,6 +72,8 @@ export function normalizedDiagramView(value = {}) {
69
72
  cameraZoom: clamp(Number(view.cameraZoom) || 1, .5, 5),
70
73
  gap: clamp(Number(view.gap ?? 20) || 0, 0, 150),
71
74
  labels: view.labels !== false,
75
+ theme: ["auto", "light", "dark"].includes(view.theme) ? view.theme : "auto",
76
+ grid: view.grid !== false,
72
77
  showOrientationCube: view.showOrientationCube !== false,
73
78
  relations: ["all", "within", "selected", "callers", "called", "none"].includes(view.relations) ? view.relations : "all",
74
79
  focus: {
@@ -188,13 +193,22 @@ export class DiagramView {
188
193
  renderOptions(extra = {}) {
189
194
  const state = this.config;
190
195
  const focused = this._focusedIds();
196
+ const background = state.background || (state.theme === "dark" ? "#2d2f31" : state.theme === "light" ? "#f5f5f5" : null)
197
+ || globalThis.getComputedStyle?.(this.root).backgroundColor || "#f5f5f5";
198
+ const color = new THREE.Color(background);
199
+ const dark = color.r * .2126 + color.g * .7152 + color.b * .0722 < .32;
200
+ // Computed CSS colours are rgb(...); SVG backdrop blending requires #rrggbb.
201
+ const canvasColor = `#${color.getHexString()}`;
191
202
  return {
192
203
  cameraView: state.cameraView, cameraFrame: { zoom: state.cameraZoom, aspect: state.frameAspect || this.root.clientWidth / Math.max(1, this.root.clientHeight) },
193
204
  includeIds: state.includeIds, relationGroups: state.relationGroups,
194
205
  relations: state.relations, relationIndexes: this._relationIndexes(),
195
206
  focus: focused ? { ids: [...focused], ...state.focus } : null,
196
207
  gap: state.gap, labels: state.labels, placements: state.placements,
197
- background: "#f5f5f5", ...extra
208
+ background: canvasColor, canvasColor,
209
+ labelColor: dark ? "#e9f0f5" : "#172c3a",
210
+ connectionColor: dark ? "#b1c7d3" : "#58788d",
211
+ ...(dark ? { containerInk: "#e9f0f5" } : {}), ...extra
198
212
  };
199
213
  }
200
214
 
@@ -250,9 +264,23 @@ export class DiagramView {
250
264
  this._refreshQueued = false;
251
265
  if (this.atlas.viewMode !== "2d" || !this.atlas.ready || !this.root.clientWidth) return false;
252
266
  this._syncControls();
267
+ this.root.style.backgroundColor = this.config.background || (this.config.theme === "dark" ? "#2d2f31" : this.config.theme === "light" ? "#f5f5f5" : "");
268
+ this.root.style.backgroundImage = this.config.grid ? "" : "none";
269
+ if (this.config.theme === "auto") this.root.style.removeProperty("--lc-diagram-dot");
270
+ else this.root.style.setProperty("--lc-diagram-dot", this.config.theme === "dark" ? "#73777c" : "#b3b3b3");
253
271
  const options = this.renderOptions({ faceCache: new Map(), background: null, maskGrid: true });
254
272
  const viewport = { width: this.root.clientWidth, height: this.root.clientHeight };
255
- const fit = result => Math.min(1, (viewport.width - 32) / result.width, (viewport.height - 32) / result.height);
273
+ const toolbar = this.atlas.toolbar;
274
+ const insets = {
275
+ top: this.atlas.shadowRoot.querySelector(".hint")?.hidden ? 16 : 34,
276
+ right: 16,
277
+ bottom: this.atlas.toolbarVisible && !toolbar.hidden ? toolbar.offsetHeight + 44 : 16,
278
+ left: this.cube.hidden ? 16 : 132
279
+ };
280
+ this.content.style.padding = `${insets.top}px ${insets.right}px ${insets.bottom}px ${insets.left}px`;
281
+ const fit = result => Math.min(1,
282
+ (viewport.width - insets.left - insets.right) / result.width,
283
+ (viewport.height - insets.top - insets.bottom) / result.height);
256
284
  const initial = this.atlas.exportDiagramSvg({ ...options, measureOnly: true });
257
285
  let zoom = this.zoom === "fit" ? fit(initial) : this.zoom;
258
286
  for (let i = 0; i < (this.zoom === "fit" ? 8 : 1); i += 1) {
@@ -264,6 +292,7 @@ export class DiagramView {
264
292
  zoom = next;
265
293
  }
266
294
  const result = this.atlas.exportDiagramSvg({ ...options, observerScale: zoom });
295
+ if (this.zoom === "fit") this._lastFitZoom = zoom;
267
296
  this.result = result;
268
297
  const previousX = this.root.scrollLeft, previousY = this.root.scrollTop;
269
298
  this.content.innerHTML = result.svg;
@@ -339,8 +368,7 @@ export class DiagramView {
339
368
 
340
369
  focusSelection() {
341
370
  if (this.zoom === "fit" && this.result) {
342
- const fit = Math.min(1, (this.root.clientWidth - 32) / this.result.width,
343
- (this.root.clientHeight - 32) / this.result.height);
371
+ const fit = this._lastFitZoom || 1;
344
372
  this.zoom = clamp(Math.max(.75, fit * 1.8), .08, 4);
345
373
  this.refresh();
346
374
  }
@@ -358,8 +386,8 @@ export class DiagramView {
358
386
  if (!action) return false;
359
387
  if (action === "diagram-fit") { this.zoom = "fit"; this.refresh(); return true; }
360
388
  if (action === "diagram-plus" || action === "diagram-minus") {
361
- const current = this.zoom === "fit" ? Math.min(1, (this.root.clientWidth - 32) / (this.result?.width || 1), (this.root.clientHeight - 32) / (this.result?.height || 1)) : this.zoom;
362
- this.zoom = clamp(current * (action === "diagram-plus" ? 1.4 : 1 / 1.4), .08, 4);
389
+ const current = this.zoom === "fit" ? this._lastFitZoom || 1 : this.zoom;
390
+ this.zoom = clamp(current * (action === "diagram-plus" ? 1.15 : 1 / 1.15), .08, 4);
363
391
  this.refresh(); return true;
364
392
  }
365
393
  if (action === "labels") { this.setConfig({ labels: !this.config.labels }, { layout: true }); return true; }
@@ -479,8 +507,7 @@ export class DiagramView {
479
507
  const remainingY = dy + this.root.scrollTop - oldY;
480
508
  if ((remainingX || remainingY) && this.config.cameraZoom > 1) {
481
509
  const angles = cameraAngles(this.config.cameraView);
482
- const zoom = this.zoom === "fit" ? Math.min(1, this.root.clientWidth / (this.result?.width || 1),
483
- this.root.clientHeight / (this.result?.height || 1)) : this.zoom;
510
+ const zoom = this.zoom === "fit" ? this._lastFitZoom || 1 : this.zoom;
484
511
  const target = panTarget(this.config.cameraView.target, angles, remainingX, remainingY, 80 * Math.max(.08, zoom));
485
512
  this.setConfig({ cameraView: cameraView(target, angles.yaw, angles.pitch) }, { layout: true, defer: true });
486
513
  }
@@ -494,7 +521,7 @@ export class DiagramView {
494
521
  this.root.addEventListener("wheel", event => {
495
522
  if (!event.altKey && !event.ctrlKey) return;
496
523
  event.preventDefault();
497
- const zoom = this.zoom === "fit" ? Math.min(1, this.root.clientWidth / (this.result?.width || 1), this.root.clientHeight / (this.result?.height || 1)) : this.zoom;
524
+ const zoom = this.zoom === "fit" ? this._lastFitZoom || 1 : this.zoom;
498
525
  this.zoom = clamp(zoom * (event.deltaY < 0 ? 1.15 : 1 / 1.15), .08, 4);
499
526
  this.scheduleRefresh();
500
527
  }, { passive: false });
@@ -944,9 +944,11 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
944
944
  let cachedImage = null;
945
945
  if (typeof image === "string" && image) {
946
946
  cachedImage = cachedGeometryImage(image);
947
- material.map = new THREE.Texture(cachedImage?.canvas || new Image());
947
+ material.map = cachedImage
948
+ ? new THREE.CanvasTexture(cachedImage.canvas)
949
+ : part.fallbackIcon ? iconTexture(part.fallbackIcon) : new THREE.Texture(new Image());
948
950
  if (!cachedImage) {
949
- const bitmap = material.map.image;
951
+ const bitmap = part.fallbackIcon ? new Image() : material.map.image;
950
952
  bitmap.crossOrigin = "anonymous";
951
953
  let cancelled = false;
952
954
  let objectUrl = null;
@@ -964,6 +966,7 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
964
966
  const onLoad = () => {
965
967
  removeListeners();
966
968
  if (cancelled || !material.map) { releaseObjectUrl(); return; }
969
+ let loadedImage = bitmap;
967
970
  if (svgSource) {
968
971
  try {
969
972
  const aspect = (bitmap.naturalWidth || 1) / Math.max(1, bitmap.naturalHeight || 1);
@@ -975,16 +978,27 @@ function createGeometryContent(definition, entity, modelPalette, invalidate, rep
975
978
  if (!context) throw new Error("Canvas 2D is unavailable");
976
979
  context.clearRect(0, 0, canvas.width, canvas.height);
977
980
  context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
978
- material.map.image = canvas;
981
+ loadedImage = canvas;
979
982
  rememberGeometryImage(image, canvas, bitmap.naturalWidth / Math.max(1, bitmap.naturalHeight));
980
983
  } catch (error) {
981
984
  reportWarning?.({ code: "SVG_RASTERIZE_FAILED", geometry: definition.id, entityId: entity.id, image, error });
985
+ if (part.fallbackIcon) loadedImage = null;
982
986
  }
983
987
  }
984
988
  releaseObjectUrl();
985
989
  const imageAspect = bitmap.naturalWidth / Math.max(1, bitmap.naturalHeight);
986
- fitGeometryImage(part, mesh, material.map, imageAspect);
987
- material.map.needsUpdate = true;
990
+ if (loadedImage) {
991
+ // A 2D projection may have read the fallback map already. Swap the
992
+ // complete texture so WebGL also uploads the decoded image.
993
+ const previousMap = material.map;
994
+ const nextMap = loadedImage === bitmap ? new THREE.Texture(bitmap) : new THREE.CanvasTexture(loadedImage);
995
+ nextMap.colorSpace = THREE.SRGBColorSpace;
996
+ fitGeometryImage(part, mesh, nextMap, imageAspect);
997
+ nextMap.needsUpdate = true;
998
+ material.map = nextMap;
999
+ material.needsUpdate = true;
1000
+ previousMap.dispose();
1001
+ }
988
1002
  invalidate?.();
989
1003
  };
990
1004
  const onError = () => {
@@ -1658,7 +1672,8 @@ class LiquidCarsAtlas extends HTMLElement {
1658
1672
  }
1659
1673
 
1660
1674
  _syncDocumentToolbar() {
1661
- const canNavigate = Boolean(this.documentResolver) && this._visitIndex >= 0;
1675
+ const canNavigate = Boolean(this.documentResolver) && this._visitIndex >= 0
1676
+ && (!this._toolbarControls || this._toolbarControls.has("navigation"));
1662
1677
  const group = this.navigationToolbar?.querySelector('[data-toolbar-group="navigation"]');
1663
1678
  if (group) group.hidden = !canNavigate;
1664
1679
  if (this.navigationToolbar) this.navigationToolbar.hidden = !canNavigate || !this.toolbarVisible;
@@ -2147,9 +2162,37 @@ class LiquidCarsAtlas extends HTMLElement {
2147
2162
  return this.setFocusFilter({ mode: "all" }, options);
2148
2163
  }
2149
2164
 
2165
+ _setToolbarControls(value) {
2166
+ const controls = Array.isArray(value) ? new Set(value.map(item => String(item).trim())) : null;
2167
+ this._toolbarControls = controls;
2168
+ if (!this.toolbar) return;
2169
+ const allowed = (...names) => !controls || controls.has("all") || names.some(name => controls.has(name));
2170
+ const group = this.toolbar;
2171
+ const buttons = {
2172
+ "view-mode": ["mode", "view-mode"],
2173
+ minus: ["zoom", "minus"], plus: ["zoom", "plus"], fit: ["fit"], front: ["front"],
2174
+ "diagram-minus": ["zoom", "diagram-minus"], "diagram-plus": ["zoom", "diagram-plus"],
2175
+ "diagram-fit": ["fit", "diagram-fit"]
2176
+ };
2177
+ for (const button of group.querySelectorAll('.toolbar-group > button[data-action]')) {
2178
+ button.hidden = !allowed(...(buttons[button.dataset.action] || [button.dataset.action]));
2179
+ }
2180
+ for (const menu of group.querySelectorAll('.toolbar-group > .toolbar-menu')) {
2181
+ const name = menu.dataset.toolbarMenu;
2182
+ menu.hidden = !allowed(name, name?.replace(/^diagram-/, ""));
2183
+ if (menu.hidden) menu.open = false;
2184
+ }
2185
+ for (const section of group.querySelectorAll('.toolbar-group:not([data-toolbar-group="navigation"])')) {
2186
+ section.hidden = !section.querySelector(':scope > button[data-action]:not([hidden]), :scope > .toolbar-menu:not([hidden])');
2187
+ }
2188
+ this._syncDocumentToolbar();
2189
+ }
2190
+
2150
2191
  _applyModelRenderOptions() {
2151
2192
  const render = { ...this.modelData?.render, ...this.modelData?.render?.views?.["3d"] };
2152
2193
  if (!render || typeof render !== "object") return this;
2194
+ const toolbar = this.modelData?.render?.ui?.toolbar ?? render.toolbar;
2195
+ this._setToolbarControls(Array.isArray(toolbar) ? toolbar : this.modelData?.render?.ui?.controls);
2153
2196
  if (RELATION_DISPLAY_MODES.includes(render.relationMode) && render.relationMode !== this.relationMode) this.setRelationMode(render.relationMode);
2154
2197
  if (normalizeRelationMarker(render.relationMarker) !== this.relationMarker) this.setRelationMarker(render.relationMarker, { emit: false, source: "model" });
2155
2198
  if (Array.isArray(render.visibleRelationGroups)) this.setVisibleRelationGroups(render.visibleRelationGroups, { emit: false, source: "model" });
@@ -2157,7 +2200,7 @@ class LiquidCarsAtlas extends HTMLElement {
2157
2200
  if (Object.hasOwn(render, "selectionMode") && render.selectionMode !== this.selectionMode) this.setSelectionMode(render.selectionMode);
2158
2201
  const selectionNavigation = render.navigation?.onSelect ?? render.selectionNavigation;
2159
2202
  if (selectionNavigation != null && selectionNavigation !== this.selectionNavigation) this.setSelectionNavigation(selectionNavigation);
2160
- if (Object.hasOwn(render, "toolbar") && (render.toolbar !== false) !== this.toolbarVisible) this.setToolbarVisible(render.toolbar !== false);
2203
+ if (toolbar !== undefined && (toolbar !== false) !== this.toolbarVisible) this.setToolbarVisible(toolbar !== false);
2161
2204
  if (Object.hasOwn(render, "shellLabels") && (render.shellLabels !== false) !== this.showShellLabels) this.setShellLabels(render.shellLabels !== false);
2162
2205
  if (Object.hasOwn(render, "itemFacing") && normalizeItemFacing(render.itemFacing) !== this.itemFacing) this.setItemFacing(render.itemFacing, { emit: false, source: "model" });
2163
2206
  if (render.focus) this.setFocusFilter(render.focus, { emit: false });
@@ -2676,7 +2719,7 @@ class LiquidCarsAtlas extends HTMLElement {
2676
2719
  if (atlas) atlas.dataset.panel = this.panelVisible ? "visible" : "hidden";
2677
2720
  const toolbarVisible = this.toolbarVisible;
2678
2721
  const panelVisible = this.panelVisible;
2679
- const hintVisible = theme.ui.hint !== false;
2722
+ const hintVisible = (this.modelData?.render?.ui?.hint ?? theme.ui.hint) !== false;
2680
2723
  if (toolbar) {
2681
2724
  toolbar.dataset.hidden = String(!toolbarVisible);
2682
2725
  toolbar.hidden = !toolbarVisible;