@liquidcars/atlas 0.1.21 → 0.1.23

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 CHANGED
@@ -50,10 +50,13 @@ También se puede cargar el modelo desde JavaScript:
50
50
  const atlas = document.querySelector('liquidcars-atlas');
51
51
  atlas.model = model;
52
52
  atlas.select('distribution-agreement');
53
+ atlas.selectRelation(0); // también acepta el id de una relación
53
54
  atlas.setRelationMode('all'); // 'all' | 'selected' | 'none'
54
55
  atlas.setToolbarVisible(true); // también se puede usar toolbar="on" | "off"
55
56
  atlas.zoomIn();
56
57
  atlas.zoomOut();
58
+ atlas.panBy(-1.5, 0); // desplazamiento horizontal/vertical en el plano de cámara
59
+ atlas.panTo(0, 0, 0); // centra la cámara en un punto del mundo
57
60
  atlas.fit();
58
61
  atlas.frontView();
59
62
  atlas.setShellLabels(true);
@@ -76,7 +79,11 @@ atlas.removeRelation(0);
76
79
  atlas.removeEntity('service');
77
80
 
78
81
  atlas.addEventListener('lc-select', event => {
79
- console.log('Entidad seleccionada:', event.detail.id, event.detail.entity);
82
+ if (event.detail.kind === 'entity') {
83
+ console.log('Entidad seleccionada:', event.detail.id, event.detail.entity);
84
+ } else if (event.detail.kind === 'relation') {
85
+ console.log('Relación seleccionada:', event.detail.index, event.detail.relation);
86
+ }
80
87
  });
81
88
  atlas.addEventListener('lc-model-change', event => {
82
89
  console.log('Modelo actualizado:', event.detail.kind);
@@ -90,9 +97,11 @@ por lo que una aplicación puede adaptar la apariencia sin modificar el bundle.
90
97
  Las propiedades `atlas.theme` y `atlas.styles` son alias prácticos de esta configuración.
91
98
 
92
99
  Eventos disponibles: `lc-ready`, `lc-error`, `lc-select`, `lc-model-change`,
93
- `lc-relation-mode` y `lc-disconnect`. `lc-select` incluye `{ id, entity,
94
- source }` en `event.detail`; cuando se vuelve a la vista general emite `id:
95
- null`. Los eventos de selección y cambios de modelo son `bubbles` y
100
+ `lc-relation-mode` y `lc-disconnect`. `lc-select` usa un detalle discriminado:
101
+ las entidades incluyen `{ kind: 'entity', id, entity, source }` y las relaciones
102
+ `{ kind: 'relation', id, index, relation, source }`. Los campos históricos `id`
103
+ y `entity` se conservan para las selecciones de entidad. Al volver a la vista
104
+ general, `kind`, `id`, `entity` y `relation` son `null`. Los eventos de selección y cambios de modelo son `bubbles` y
96
105
  `composed`, por lo que funcionan también al integrar el componente en otras
97
106
  capas de Web Components.
98
107
 
@@ -113,13 +122,14 @@ como `setClickBehavior()`).
113
122
  El campo visual opcional `mode` admite `directed` (por defecto),
114
123
  `bidirectional` y `broken`. En modo bidireccional los indicadores animados
115
124
  circulan en ambos sentidos por el mismo tubo; en modo broken los dos tramos
116
- aparecen en rojo, con una X y un anillo detenido en el punto de interrupción.
125
+ se sustituyen por un único tubo rojo con tres anillos que vibran localmente,
126
+ distribuidos a lo largo del recorrido.
117
127
 
118
128
  Geometrías incluidas en esta primera versión: `box`, `cylinder`, `sphere`, `cone`, `frustum-cone`, `frustum-pyramid`, `frustum-triangle`, `prism`, `pyramid-square`, `pyramid-triangle`, `octahedron`, `dodecahedron` e `icosahedron`. Las variantes `frustum-*` son sólidos truncados con dos caras finitas paralelas; las dimensiones `s` se aplican sobre su caja final normalizada.
119
129
 
120
130
  La paleta puede cambiarse después de cargar el modelo mediante `atlas.setPalette({ nombre: color })` o asignando `atlas.palette = { ... }`. El cambio actualiza las piezas, aristas y relaciones sin reconstruir el modelo.
121
131
 
122
- Las acciones de navegación y exploración de la toolbar también están disponibles para la página anfitriona: `zoomIn()`, `zoomOut()`, `fit()`, `frontView()`, `setShellLabels()`, `toggleShellLabels()`, `openAllContainers()` y `closeAllContainers()`. Así se puede construir una barra de controles propia sin depender de la toolbar interna.
132
+ Las acciones de navegación y exploración también están disponibles para la página anfitriona: `zoomIn()`, `zoomOut()`, `panBy()`, `panTo()`, `fit()`, `frontView()`, `setShellLabels()`, `toggleShellLabels()`, `openAllContainers()` y `closeAllContainers()`. El usuario puede desplazar la vista con el botón derecho, con `Shift` + arrastre o mediante dos dedos en una pantalla táctil. Así se puede construir una barra de controles propia sin depender de la toolbar interna.
123
133
 
124
134
  La escena también se puede editar en caliente. `updateModel(patch)` reemplaza
125
135
  las secciones indicadas; `addEntity`, `updateEntity` y `removeEntity` gestionan
@@ -141,9 +151,18 @@ Una vez publicado, los consumidores podrán instalarlo con `npm install @liquidc
141
151
  Para probar el paquete sin publicarlo, basta con instalar el tarball generado:
142
152
 
143
153
  ```bash
144
- npm install ./liquidcars-atlas-0.1.21.tgz
154
+ npm install ./liquidcars-atlas-0.1.22.tgz
145
155
  ```
146
156
 
147
157
  ## Estado
148
158
 
149
- `0.1.21` mantiene la corrección del desplazamiento interno del texto y ancla las etiquetas a las dimensiones reales de las geometrías redondeadas. Además fija un margen superior absoluto para las etiquetas secundarias de contenedores abiertos, situado dentro del frontal del contenedor y sin depender de sus dimensiones. También corrige los sólidos truncados, normaliza sus dimensiones finales, refuerza la visibilidad de la toolbar con el atributo `toolbar="on"`/`"off"` y `setToolbarVisible()`, desacopla la paleta por defecto de nombres de dominio, añade actualización programática de la paleta con `setPalette`, expone las acciones de navegación para controles externos y mantiene siempre visibles las ayudas de interacción dentro del viewport. Esta versión añade edición en caliente de entidades y relaciones, eventos compuestos de selección y los modos de relación `directed`, `bidirectional` y `broken`; las relaciones rotas se representan con una única malla de tubo rojo y tres anillos oscilantes distribuidos a lo largo del recorrido.
159
+ `0.1.23` añade desplazamiento lateral de cámara mediante ratón, teclado
160
+ modificador y gestos táctiles, además de la API pública `panBy()` y `panTo()`.
161
+ Se conserva la selección contextual de relaciones introducida en `0.1.22`: el usuario puede
162
+ seleccionar un tubo directamente, el componente lo realza, muestra sus extremos
163
+ y modo en el panel y emite el mismo evento `lc-select` con `kind: 'relation'`.
164
+ La API pública añade `selectRelation()`, `selectedRelation` y `selection`, y la
165
+ selección se conserva durante las mutaciones del modelo siempre que el elemento
166
+ siga existiendo. Se mantienen las correcciones de geometría, toolbar, etiquetas,
167
+ paleta externa, edición en caliente y los modos `directed`, `bidirectional` y
168
+ `broken` introducidos en las versiones anteriores.
@@ -1,4 +1,4 @@
1
- /*! LiquidCars Atlas 0.1.21 | MIT */
1
+ /*! LiquidCars Atlas 0.1.22 | MIT */
2
2
 
3
3
  // src/three.core.js
4
4
  /**
@@ -29371,7 +29371,7 @@ var CSS = [
29371
29371
  ":host{display:block;position:relative;height:var(--lc-height,680px);min-height:420px;color:var(--lc-panel-title,#e9f0f5);font:14px Inter,system-ui,-apple-system,sans-serif;background:var(--lc-bg,#0a111a);border:1px solid #294252;border-radius:12px;overflow:hidden;contain:content}",
29372
29372
  "*{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 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 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}}"
29373
29373
  ].join("");
29374
- var TEMPLATE = '<div class="atlas"><section class="viewport"><canvas tabindex="0" aria-label="Modelo 3D interactivo"></canvas><div class="toolbar"><button data-action="minus" aria-label="Alejar">\u2212</button><button data-action="plus" aria-label="Acercar">+</button><i></i><button data-action="fit">\u2316 Encuadrar</button><button data-action="front">\u25B1 Frontal</button><button data-action="labels" class="active" aria-pressed="true">\u25A7 Etiquetas</button><i></i><button data-action="relations-all" aria-pressed="false">Todas</button><button data-action="relations-selected" class="active" aria-pressed="true">Selecci\xF3n</button><button data-action="relations-none" aria-pressed="false">Ocultar</button></div><div class="hint">Arrastra para girar \xB7 rueda para zoom \xB7 clic para seleccionar</div></section><aside class="panel" aria-live="polite"></aside></div>';
29374
+ var TEMPLATE = '<div class="atlas"><section class="viewport"><canvas tabindex="0" aria-label="Modelo 3D interactivo"></canvas><div class="toolbar"><button data-action="minus" aria-label="Alejar">\u2212</button><button data-action="plus" aria-label="Acercar">+</button><i></i><button data-action="fit">\u2316 Encuadrar</button><button data-action="front">\u25B1 Frontal</button><button data-action="labels" class="active" aria-pressed="true">\u25A7 Etiquetas</button><i></i><button data-action="relations-all" aria-pressed="false">Todas</button><button data-action="relations-selected" class="active" aria-pressed="true">Selecci\xF3n</button><button data-action="relations-none" aria-pressed="false">Ocultar</button></div><div class="hint">Gira: arrastra \xB7 Pan: \u21E7+arrastra / 2 dedos \xB7 Zoom: rueda o pellizco \xB7 Selecciona: clic</div></section><aside class="panel" aria-live="polite"></aside></div>';
29375
29375
  function escapeHtml(value) {
29376
29376
  return String(value ?? "").replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
29377
29377
  }
@@ -29595,6 +29595,7 @@ var LiquidCarsAtlas = class extends HTMLElement {
29595
29595
  this.nodes = /* @__PURE__ */ new Map();
29596
29596
  this.open = /* @__PURE__ */ new Set();
29597
29597
  this.selected = null;
29598
+ this.selectedRelationIndex = null;
29598
29599
  this.modelData = null;
29599
29600
  this.theme = normalizeTheme();
29600
29601
  this.relationMode = this.getAttribute("relation-mode") || "selected";
@@ -29607,6 +29608,7 @@ var LiquidCarsAtlas = class extends HTMLElement {
29607
29608
  this.focus = new Vector3();
29608
29609
  this.targetFocus = new Vector3();
29609
29610
  this.drag = null;
29611
+ this.pointers = /* @__PURE__ */ new Map();
29610
29612
  this.relationGroup = new Group();
29611
29613
  this.scene.add(this.relationGroup);
29612
29614
  this.relations = [];
@@ -29651,6 +29653,14 @@ var LiquidCarsAtlas = class extends HTMLElement {
29651
29653
  get selectedId() {
29652
29654
  return this.selected?.id || null;
29653
29655
  }
29656
+ get selectedRelation() {
29657
+ return this._publicRelation(this.selectedRelationIndex);
29658
+ }
29659
+ get selection() {
29660
+ if (this.selected) return { kind: "entity", id: this.selected.id, entity: this._publicEntity(this.selected.id) };
29661
+ const relation = this.selectedRelation;
29662
+ return relation ? { kind: "relation", id: relation.id || null, index: relation.index, relation } : null;
29663
+ }
29654
29664
  get clickBehavior() {
29655
29665
  return this.selectionMode;
29656
29666
  }
@@ -29673,10 +29683,17 @@ var LiquidCarsAtlas = class extends HTMLElement {
29673
29683
  s: Array.isArray(entity.s) ? [...entity.s] : [1, 1, 1]
29674
29684
  } : null;
29675
29685
  }
29686
+ _publicRelation(reference) {
29687
+ const index = this._relationIndex(reference);
29688
+ const relation = this.modelData?.relations?.[index];
29689
+ return relation ? { ...relation, index } : null;
29690
+ }
29676
29691
  _commitModelChange(kind, detail = {}, options = {}) {
29677
29692
  const selectedId = options.preserveSelection === false ? null : this.selected?.id;
29693
+ const selectedRelation = options.preserveSelection === false ? null : this._publicRelation(this.selectedRelationIndex);
29694
+ const selectedRelationReference = selectedRelation?.id ?? selectedRelation?.index ?? null;
29678
29695
  this.modelData = normalizeModel(this.modelData);
29679
- this._build({ preserveSelection: selectedId });
29696
+ this._build({ preserveSelection: selectedId, preserveRelation: selectedRelationReference });
29680
29697
  this._emit("lc-model-change", {
29681
29698
  kind,
29682
29699
  model: this.modelData,
@@ -29735,8 +29752,11 @@ var LiquidCarsAtlas = class extends HTMLElement {
29735
29752
  }
29736
29753
  }
29737
29754
  if (!this.modelData.entities.some((item) => item.id === id)) throw new Error(`No existe una entidad con id "${id}".`);
29755
+ const selectedRelation = this.modelData.relations[this.selectedRelationIndex];
29738
29756
  this.modelData.entities = this.modelData.entities.filter((item) => !removed.has(item.id));
29739
29757
  this.modelData.relations = this.modelData.relations.filter((relation) => !removed.has(relation.from) && !removed.has(relation.to));
29758
+ const nextSelectedRelationIndex = selectedRelation ? this.modelData.relations.indexOf(selectedRelation) : -1;
29759
+ this.selectedRelationIndex = nextSelectedRelationIndex >= 0 ? nextSelectedRelationIndex : null;
29740
29760
  for (const removedId of removed) this.open.delete(removedId);
29741
29761
  if (this.selected && removed.has(this.selected.id)) this.selected = null;
29742
29762
  return this._commitModelChange("entity-remove", { id, removed: [...removed] });
@@ -29770,6 +29790,8 @@ var LiquidCarsAtlas = class extends HTMLElement {
29770
29790
  const index = this._relationIndex(reference);
29771
29791
  if (index < 0 || index >= this.modelData.relations.length) throw new Error("No existe la relaci\xF3n indicada.");
29772
29792
  const [relation] = this.modelData.relations.splice(index, 1);
29793
+ if (this.selectedRelationIndex === index) this.selectedRelationIndex = null;
29794
+ else if (this.selectedRelationIndex > index) this.selectedRelationIndex -= 1;
29773
29795
  return this._commitModelChange("relation-remove", { index, relation: { ...relation } }, options);
29774
29796
  }
29775
29797
  get palette() {
@@ -29809,6 +29831,27 @@ var LiquidCarsAtlas = class extends HTMLElement {
29809
29831
  this.invalidate();
29810
29832
  return this;
29811
29833
  }
29834
+ panBy(horizontal = 0, vertical = 0) {
29835
+ const direction = this.camera.getWorldDirection(new Vector3());
29836
+ const right = new Vector3().crossVectors(direction, this.camera.up).normalize();
29837
+ const up = new Vector3().crossVectors(right, direction).normalize();
29838
+ this.targetFocus.addScaledVector(right, Number(horizontal) || 0);
29839
+ this.targetFocus.addScaledVector(up, Number(vertical) || 0);
29840
+ this.invalidate();
29841
+ return this;
29842
+ }
29843
+ panTo(x = 0, y = 0, z = 0) {
29844
+ const value = Array.isArray(x) ? x : x && typeof x === "object" ? [x.x, x.y, x.z] : [x, y, z];
29845
+ this.targetFocus.set(Number(value[0]) || 0, Number(value[1]) || 0, Number(value[2]) || 0);
29846
+ this.invalidate();
29847
+ return this;
29848
+ }
29849
+ _panByPixels(dx, dy) {
29850
+ const rect = this.canvas.getBoundingClientRect();
29851
+ const distance = Math.max(1, this.camera.position.distanceTo(this.focus));
29852
+ const worldPerPixel = 2 * distance * Math.tan(MathUtils.degToRad(this.camera.fov) / 2) / Math.max(1, rect.height);
29853
+ return this.panBy(-dx * worldPerPixel, dy * worldPerPixel);
29854
+ }
29812
29855
  fit() {
29813
29856
  const bounds = new Box3();
29814
29857
  for (const node of this.nodes.values()) {
@@ -29858,6 +29901,7 @@ var LiquidCarsAtlas = class extends HTMLElement {
29858
29901
  closeAllContainers() {
29859
29902
  this.open.clear();
29860
29903
  this.selected = null;
29904
+ this.selectedRelationIndex = null;
29861
29905
  this._renderPanel();
29862
29906
  this._rebuildRelations();
29863
29907
  this.invalidate();
@@ -30027,6 +30071,7 @@ var LiquidCarsAtlas = class extends HTMLElement {
30027
30071
  _build(options = {}) {
30028
30072
  if (!this._ensureRenderer()) return;
30029
30073
  const selectedId = options.preserveSelection || null;
30074
+ const selectedRelationReference = options.preserveRelation ?? null;
30030
30075
  this._clearObjects();
30031
30076
  for (const entity of this.modelData.entities) this.nodes.set(entity.id, { ...entity });
30032
30077
  this.open = new Set([...this.open].filter((id) => this.nodes.has(id)));
@@ -30061,8 +30106,10 @@ var LiquidCarsAtlas = class extends HTMLElement {
30061
30106
  node.group.add(node.label);
30062
30107
  this.scene.add(node.group);
30063
30108
  }
30064
- this.selected = selectedId && this.nodes.has(selectedId) ? this.nodes.get(selectedId) : null;
30065
30109
  this.relations = this.modelData.relations;
30110
+ this.selected = selectedId && this.nodes.has(selectedId) ? this.nodes.get(selectedId) : null;
30111
+ const selectedRelationIndex = this._relationIndex(selectedRelationReference);
30112
+ this.selectedRelationIndex = !this.selected && selectedRelationIndex >= 0 && selectedRelationIndex < this.relations.length ? selectedRelationIndex : null;
30066
30113
  this._renderPanel();
30067
30114
  this._rebuildRelations();
30068
30115
  this.ready = true;
@@ -30097,7 +30144,9 @@ var LiquidCarsAtlas = class extends HTMLElement {
30097
30144
  }
30098
30145
  _activeRelationIndexes() {
30099
30146
  if (this.relationMode === "all") return this.relations.map((_, index) => index);
30100
- if (this.relationMode !== "selected" || !this.selected) return [];
30147
+ if (this.relationMode !== "selected") return [];
30148
+ if (this.selectedRelationIndex !== null) return [this.selectedRelationIndex];
30149
+ if (!this.selected) return [];
30101
30150
  return this.relations.map((relation, index) => relation.from === this.selected.id || relation.to === this.selected.id ? index : -1).filter((index) => index >= 0);
30102
30151
  }
30103
30152
  _rebuildRelations() {
@@ -30114,8 +30163,14 @@ var LiquidCarsAtlas = class extends HTMLElement {
30114
30163
  const delta = end.clone().sub(start);
30115
30164
  if (delta.lengthSq() < 0.01) continue;
30116
30165
  const visualMode = relationVisualMode(relation);
30166
+ const isSelected = index === this.selectedRelationIndex;
30167
+ const pickTube = new Mesh(new TubeGeometry(new LineCurve3(start, end), 10, 0.16, 6, false), new MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false }));
30168
+ pickTube.userData.relationIndex = index;
30169
+ pickTube.userData.relationPick = true;
30170
+ this.relationGroup.add(pickTube);
30117
30171
  const makeTube = (from2, to2, opacity = 0.82, tubeColor = color, tubeRadius = 0.045) => {
30118
- const tube = new Mesh(new TubeGeometry(new LineCurve3(from2, to2), 10, tubeRadius, 6, false), new MeshBasicMaterial({ color: tubeColor, transparent: true, opacity }));
30172
+ const displayColor = isSelected ? tubeColor.clone().lerp(new Color(16777215), 0.28) : tubeColor;
30173
+ const tube = new Mesh(new TubeGeometry(new LineCurve3(from2, to2), 10, isSelected ? tubeRadius * 1.45 : tubeRadius, 6, false), new MeshBasicMaterial({ color: displayColor, transparent: true, opacity: isSelected ? 1 : opacity }));
30119
30174
  tube.userData.relationIndex = index;
30120
30175
  this.relationGroup.add(tube);
30121
30176
  };
@@ -30125,13 +30180,15 @@ var LiquidCarsAtlas = class extends HTMLElement {
30125
30180
  // A broken communication remains a continuous red tube. Its rings
30126
30181
  // oscillate locally around their positions instead of travelling
30127
30182
  // along the connection, conveying a failed/retrying signal.
30128
- const brokenTube = new Mesh(new TubeGeometry(new LineCurve3(start, end), 10, 0.055, 6, false), new MeshBasicMaterial({ color: brokenColor, transparent: true, opacity: 0.88 }));
30183
+ const displayBrokenColor = isSelected ? brokenColor.clone().lerp(new Color(16777215), 0.22) : brokenColor;
30184
+ const brokenTube = new Mesh(new TubeGeometry(new LineCurve3(start, end), 10, isSelected ? 0.08 : 0.055, 6, false), new MeshBasicMaterial({ color: displayBrokenColor, transparent: true, opacity: isSelected ? 1 : 0.88 }));
30129
30185
  brokenTube.userData.relationIndex = index;
30130
30186
  this.relationGroup.add(brokenTube);
30131
30187
  const amplitude = Math.min(0.18, Math.max(0.07, delta.length() * 0.018));
30132
30188
  for (let ringIndex = 0; ringIndex < 3; ringIndex++) {
30133
30189
  const position = start.clone().lerp(end, (ringIndex + 1) / 4);
30134
- const ring = new Mesh(new TorusGeometry(0.085, 0.024, 8, 20), new MeshBasicMaterial({ color: brokenColor, transparent: true, opacity: 1 }));
30190
+ const ring = new Mesh(new TorusGeometry(isSelected ? 0.105 : 0.085, isSelected ? 0.03 : 0.024, 8, 20), new MeshBasicMaterial({ color: displayBrokenColor, transparent: true, opacity: 1 }));
30191
+ ring.userData.relationIndex = index;
30135
30192
  ring.userData.phase = ringIndex / 3;
30136
30193
  ring.userData.oscillate = true;
30137
30194
  ring.userData.start = position.clone().addScaledVector(direction, -amplitude);
@@ -30143,7 +30200,9 @@ var LiquidCarsAtlas = class extends HTMLElement {
30143
30200
  }
30144
30201
  makeTube(start, end);
30145
30202
  for (let ringIndex = 0; ringIndex < 4; ringIndex++) {
30146
- const ring = new Mesh(new TorusGeometry(0.068, 0.018, 8, 20), new MeshBasicMaterial({ color, transparent: true, opacity: 0.9 }));
30203
+ const displayColor = isSelected ? color.clone().lerp(new Color(16777215), 0.28) : color;
30204
+ const ring = new Mesh(new TorusGeometry(isSelected ? 0.088 : 0.068, isSelected ? 0.024 : 0.018, 8, 20), new MeshBasicMaterial({ color: displayColor, transparent: true, opacity: isSelected ? 1 : 0.9 }));
30205
+ ring.userData.relationIndex = index;
30147
30206
  ring.userData.phase = ringIndex / 4;
30148
30207
  ring.userData.direction = visualMode === "bidirectional" && ringIndex % 2 ? -1 : 1;
30149
30208
  ring.userData.start = start.clone();
@@ -30156,6 +30215,16 @@ var LiquidCarsAtlas = class extends HTMLElement {
30156
30215
  _renderPanel() {
30157
30216
  if (!this.modelData) return;
30158
30217
  const node = this.selected;
30218
+ const selectedRelation = this._publicRelation(this.selectedRelationIndex);
30219
+ if (selectedRelation) {
30220
+ const mode = relationVisualMode(selectedRelation);
30221
+ const title = selectedRelation.label || `${selectedRelation.from} \u2192 ${selectedRelation.to}`;
30222
+ this.panel.innerHTML = '<button class="back" data-action="overview">\u2190 Vista de conjunto</button><div class="eyebrow">RELACI\xD3N</div><h2>' + escapeHtml(title) + '</h2><div class="sub">' + escapeHtml(mode) + '</div><h3>ORIGEN</h3><button data-action="relation-endpoint" data-id="' + escapeHtml(selectedRelation.from) + '">' + escapeHtml(selectedRelation.from) + '</button><h3>DESTINO</h3><button data-action="relation-endpoint" data-id="' + escapeHtml(selectedRelation.to) + '">' + escapeHtml(selectedRelation.to) + '</button>';
30223
+ this.panel.querySelector('[data-action="overview"]')?.addEventListener("click", () => this.reset());
30224
+ this.panel.querySelectorAll('[data-action="relation-endpoint"]').forEach((button) => button.addEventListener("click", () => this.select(button.dataset.id)));
30225
+ this._applyTheme();
30226
+ return;
30227
+ }
30159
30228
  if (!node) {
30160
30229
  this.panel.innerHTML = '<div class="eyebrow">LIQUIDCARS ATLAS</div><h2>Explora el modelo.</h2><p>Selecciona un volumen para consultar su funci\xF3n y sus relaciones.</p><p class="note">El componente acepta un objeto JSON con palette, entities y relations.</p>';
30161
30230
  this._applyTheme();
@@ -30205,13 +30274,38 @@ var LiquidCarsAtlas = class extends HTMLElement {
30205
30274
  parent = parent.parent ? this.nodes.get(parent.parent) : null;
30206
30275
  }
30207
30276
  this.selected = node;
30277
+ this.selectedRelationIndex = null;
30208
30278
  if (node.type === "shell") this.open.add(node.id);
30209
30279
  this.targetFocus.copy(this._worldPosition(node));
30210
30280
  this.targetZoom = node.parent ? 1.35 : 1.1;
30211
30281
  this._renderPanel();
30212
30282
  this._rebuildRelations();
30213
30283
  this.invalidate();
30214
- this._emit("lc-select", { id, entity: this._publicEntity(id), source: options.source || "api" });
30284
+ if (options.emit !== false) this._emit("lc-select", { kind: "entity", id, index: null, entity: this._publicEntity(id), relation: null, source: options.source || "api" });
30285
+ return this;
30286
+ }
30287
+ selectRelation(reference, options = {}) {
30288
+ const index = this._relationIndex(reference);
30289
+ const relation = this._publicRelation(index);
30290
+ if (!relation) return;
30291
+ for (const endpoint of [relation.from, relation.to]) {
30292
+ let node = this.nodes.get(endpoint);
30293
+ while (node?.parent) {
30294
+ this.open.add(node.parent);
30295
+ node = this.nodes.get(node.parent);
30296
+ }
30297
+ }
30298
+ this.selected = null;
30299
+ this.selectedRelationIndex = index;
30300
+ const from = this.nodes.get(relation.from);
30301
+ const to = this.nodes.get(relation.to);
30302
+ if (from && to) this.targetFocus.copy(this._worldPosition(from).add(this._worldPosition(to)).multiplyScalar(0.5));
30303
+ this.targetZoom = 1.22;
30304
+ this._renderPanel();
30305
+ this._rebuildRelations();
30306
+ this.invalidate();
30307
+ if (options.emit !== false) this._emit("lc-select", { kind: "relation", id: relation.id || null, index, entity: null, relation, source: options.source || "api" });
30308
+ return this;
30215
30309
  }
30216
30310
  setRelationMode(mode) {
30217
30311
  this.relationMode = ["all", "selected", "none"].includes(mode) ? mode : "selected";
@@ -30230,7 +30324,9 @@ var LiquidCarsAtlas = class extends HTMLElement {
30230
30324
  }
30231
30325
  reset() {
30232
30326
  const previousId = this.selected?.id || null;
30327
+ const previousRelation = this._publicRelation(this.selectedRelationIndex);
30233
30328
  this.selected = null;
30329
+ this.selectedRelationIndex = null;
30234
30330
  this.open.clear();
30235
30331
  this.relationMode = "selected";
30236
30332
  this.setAttribute("relation-mode", "selected");
@@ -30252,7 +30348,8 @@ var LiquidCarsAtlas = class extends HTMLElement {
30252
30348
  this._renderPanel();
30253
30349
  this._rebuildRelations();
30254
30350
  this.invalidate();
30255
- if (previousId) this._emit("lc-select", { id: null, entity: null, previousId, source: "reset" });
30351
+ if (previousId || previousRelation) this._emit("lc-select", { kind: null, id: null, index: null, entity: null, relation: null, previousId, previousRelation, source: "reset" });
30352
+ return this;
30256
30353
  }
30257
30354
  resize() {
30258
30355
  if (!this.renderer) return;
@@ -30368,7 +30465,12 @@ var LiquidCarsAtlas = class extends HTMLElement {
30368
30465
  if (!(node.type === "shell" && this.open.has(node.id))) meshes.push(node.mesh);
30369
30466
  if (node.label?.visible) meshes.push(...node.label.children.filter((face) => face.visible));
30370
30467
  }
30371
- return this.raycaster.intersectObjects(meshes, false)[0]?.object.userData.node || null;
30468
+ const nodeHit = this.raycaster.intersectObjects(meshes, false)[0] || null;
30469
+ const relationTargets = this.relationGroup.children.filter((object) => object.userData.relationPick);
30470
+ const relationHit = this.raycaster.intersectObjects(relationTargets, false)[0] || null;
30471
+ if (nodeHit && (!relationHit || nodeHit.distance <= relationHit.distance + 0.12)) return { kind: "entity", node: nodeHit.object.userData.node };
30472
+ if (relationHit) return { kind: "relation", index: relationHit.object.userData.relationIndex };
30473
+ return nodeHit ? { kind: "entity", node: nodeHit.object.userData.node } : null;
30372
30474
  }
30373
30475
  _bindControls() {
30374
30476
  this.shadowRoot.addEventListener("click", (event) => {
@@ -30383,36 +30485,76 @@ var LiquidCarsAtlas = class extends HTMLElement {
30383
30485
  if (action === "relations-none") this.setRelationMode("none");
30384
30486
  if (action) this.invalidate();
30385
30487
  });
30488
+ this.canvas.addEventListener("contextmenu", (event) => event.preventDefault());
30386
30489
  this.canvas.addEventListener("pointerdown", (event) => {
30387
- if (event.button !== 0) return;
30388
- this.drag = { x: event.clientX, y: event.clientY, moved: false };
30490
+ if (event.pointerType !== "touch" && ![0, 1, 2].includes(event.button)) return;
30491
+ this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
30492
+ const mode = event.pointerType === "touch" ? "rotate" : event.shiftKey || event.button !== 0 ? "pan" : "rotate";
30493
+ this.drag = { pointerId: event.pointerId, pointerType: event.pointerType, button: event.button, mode, x: event.clientX, y: event.clientY, moved: false };
30494
+ if (event.pointerType === "touch" && this.pointers.size === 2) {
30495
+ const [a, b] = [...this.pointers.values()];
30496
+ this.drag = { mode: "touch-pan", x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, distance: Math.hypot(a.x - b.x, a.y - b.y), moved: true };
30497
+ }
30389
30498
  this.canvas.setPointerCapture(event.pointerId);
30390
30499
  this.canvas.style.cursor = "grabbing";
30391
30500
  });
30392
30501
  this.canvas.addEventListener("pointermove", (event) => {
30393
- if (!this.drag) return;
30502
+ if (!this.drag || !this.pointers.has(event.pointerId)) return;
30503
+ this.pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
30504
+ if (event.pointerType === "touch" && this.pointers.size >= 2) {
30505
+ const [a, b] = [...this.pointers.values()];
30506
+ const x = (a.x + b.x) / 2;
30507
+ const y = (a.y + b.y) / 2;
30508
+ const distance = Math.max(1, Math.hypot(a.x - b.x, a.y - b.y));
30509
+ if (this.drag.mode !== "touch-pan") this.drag = { mode: "touch-pan", x, y, distance, moved: true };
30510
+ else {
30511
+ this._panByPixels(x - this.drag.x, y - this.drag.y);
30512
+ this.targetZoom = Math.min(2.8, Math.max(0.55, this.targetZoom * distance / Math.max(1, this.drag.distance)));
30513
+ Object.assign(this.drag, { x, y, distance, moved: true });
30514
+ }
30515
+ this.invalidate();
30516
+ return;
30517
+ }
30518
+ if (this.drag.pointerId !== event.pointerId) return;
30394
30519
  const dx = event.clientX - this.drag.x;
30395
30520
  const dy = event.clientY - this.drag.y;
30396
30521
  if (Math.abs(dx) + Math.abs(dy) > 3) this.drag.moved = true;
30397
30522
  this.drag.x = event.clientX;
30398
30523
  this.drag.y = event.clientY;
30399
- this.yaw -= dx * 5e-3;
30400
- this.pitch = Math.max(-1.56, Math.min(1.56, this.pitch + dy * 5e-3));
30524
+ if (this.drag.mode === "pan" || event.shiftKey) this._panByPixels(dx, dy);
30525
+ else {
30526
+ this.yaw -= dx * 5e-3;
30527
+ this.pitch = Math.max(-1.56, Math.min(1.56, this.pitch + dy * 5e-3));
30528
+ }
30401
30529
  this.invalidate();
30402
30530
  });
30403
- this.canvas.addEventListener("pointerup", (event) => {
30531
+ const endPointer = (event, cancelled = false) => {
30404
30532
  const drag = this.drag;
30533
+ this.pointers.delete(event.pointerId);
30534
+ if (this.pointers.size) {
30535
+ const [pointerId, point] = this.pointers.entries().next().value;
30536
+ this.drag = { pointerId, pointerType: "touch", button: 0, mode: "rotate", x: point.x, y: point.y, moved: true };
30537
+ return;
30538
+ }
30405
30539
  this.drag = null;
30406
30540
  this.canvas.style.cursor = "grab";
30407
- if (drag && !drag.moved) {
30408
- const node = this._pick(event);
30409
- if (node) {
30410
- if (this.selectionMode === "event") this._emit("lc-select", { id: node.id, entity: this._publicEntity(node.id), source: "pointer" });
30541
+ if (!cancelled && drag && !drag.moved && drag.button === 0) {
30542
+ const picked = this._pick(event);
30543
+ if (picked?.kind === "entity") {
30544
+ const node = picked.node;
30545
+ if (this.selectionMode === "event") this._emit("lc-select", { kind: "entity", id: node.id, index: null, entity: this._publicEntity(node.id), relation: null, source: "pointer" });
30411
30546
  else this.select(node.id, { source: "pointer" });
30412
30547
  }
30548
+ if (picked?.kind === "relation") {
30549
+ const relation = this._publicRelation(picked.index);
30550
+ if (this.selectionMode === "event") this._emit("lc-select", { kind: "relation", id: relation?.id || null, index: picked.index, entity: null, relation, source: "pointer" });
30551
+ else this.selectRelation(picked.index, { source: "pointer" });
30552
+ }
30413
30553
  }
30414
30554
  this.invalidate();
30415
- });
30555
+ };
30556
+ this.canvas.addEventListener("pointerup", (event) => endPointer(event));
30557
+ this.canvas.addEventListener("pointercancel", (event) => endPointer(event, true));
30416
30558
  this.canvas.addEventListener("wheel", (event) => {
30417
30559
  event.preventDefault();
30418
30560
  this.targetZoom = Math.min(2.8, Math.max(0.55, this.targetZoom * Math.exp(-event.deltaY * 1e-3)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Reusable 3D conceptual atlas Web Component for LiquidCars-style models",
5
5
  "type": "module",
6
6
  "main": "./dist/liquidcars-atlas.js",