@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas",
3
- "version": "0.1.52",
3
+ "version": "0.2.0",
4
4
  "description": "Reusable 3D conceptual atlas Web Component for LiquidCars-style models",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -0,0 +1,125 @@
1
+ // Draws the already calculated diagram. No layout or SVG work occurs in a camera frame.
2
+ const isContainer=node=>['container','shell'].includes(node.type);
3
+ export class DiagramScene {
4
+ constructor(atlas,G){this.atlas=atlas;this.G=G;this.visuals=new Map();this.links=[];this.index=new Map();this.positions=new Map();this.batch=null;this.basic=new Map();}
5
+ material(source){
6
+ if(source.isLineBasicMaterial)return source;
7
+ if(this.basic.has(source))return this.basic.get(source);
8
+ const material=new this.G.MeshBasicMaterial({color:source.color||'#fff',map:source.map,side:source.side,alphaTest:source.alphaTest||0,transparent:true,opacity:source.userData?.atlasBaseOpacity??source.opacity??1,depthWrite:source.depthWrite,toneMapped:false});
9
+ material.userData.atlasBaseOpacity=material.opacity;this.basic.set(source,material);return material;
10
+ }
11
+ flattenMaterials(){
12
+ const originals=new Set();
13
+ for(const node of this.atlas.nodes.values()){
14
+ if(isContainer(node))continue;
15
+ node.mesh.traverse(mesh=>{if(!mesh.isMesh||mesh===node.pickMesh)return;const original=Array.isArray(mesh.material)?mesh.material:[mesh.material];const materials=original.map(source=>{originals.add(source);return this.material(source);});mesh.material=Array.isArray(mesh.material)?materials:materials[0];});
16
+ if(node.material)node.material=this.material(node.material);
17
+ if(node.appearanceMaterials)node.appearanceMaterials=node.appearanceMaterials.map(m=>this.material(m));
18
+ if(node.pickMesh)node.pickMesh.visible=false;
19
+ }
20
+ for(const material of originals)material.dispose();
21
+ }
22
+ caption(node,lines,fontSize,lineHeight,color,align){
23
+ const key=JSON.stringify([lines,fontSize,lineHeight,color,align]),visual=this.visuals.get(node.id);
24
+ if(visual?.captionKey===key)return visual.caption;
25
+ if(visual?.caption){node.group.remove(visual.caption);visual.caption.geometry.dispose();visual.caption.material.map.dispose();visual.caption.material.dispose();}
26
+ const canvas=this.atlas.ownerDocument.createElement('canvas'),ctx=canvas.getContext('2d');
27
+ ctx.font=`${fontSize}px system-ui`;
28
+ const width=Math.max(1,...lines.map(line=>ctx.measureText(line).width))+4,height=Math.max(lineHeight,lines.length*lineHeight);
29
+ const density=Math.min(4,4096/width,4096/height);
30
+ canvas.width=Math.ceil(width*density);canvas.height=Math.ceil(height*density);ctx.scale(density,density);ctx.font=`${fontSize}px system-ui`;ctx.fillStyle=color;ctx.textAlign=align;ctx.textBaseline='top';
31
+ lines.forEach((line,i)=>ctx.fillText(line,align==='left'?2:width/2,i*lineHeight));
32
+ const texture=new this.G.CanvasTexture(canvas);texture.colorSpace='srgb';
33
+ const mesh=new this.G.Mesh(new this.G.PlaneGeometry(width/this.scale,height/this.scale),new this.G.MeshBasicMaterial({map:texture,transparent:true,depthTest:false,depthWrite:false,toneMapped:false}));
34
+ mesh.renderOrder=100;mesh.userData.node=node;mesh.userData.size=[width/this.scale,height/this.scale];node.group.add(mesh);visual.captionKey=key;visual.caption=mesh;return mesh;
35
+ }
36
+ container(node,visual,width,height,color,depth){
37
+ if(!visual.fill){
38
+ visual.fill=new this.G.Mesh(new this.G.BufferGeometry(),new this.G.MeshBasicMaterial({color,depthTest:false,depthWrite:false,toneMapped:false}));
39
+ visual.border=new this.G.LineSegments(new this.G.BufferGeometry(),new this.G.LineBasicMaterial({color,transparent:true,opacity:.72,depthTest:false,depthWrite:false,toneMapped:false}));
40
+ node.group.add(visual.fill,visual.border);
41
+ }
42
+ const key=width+':'+height;
43
+ if(visual.rectangleKey!==key){
44
+ visual.rectangleKey=key;const radius=Math.min(12/this.scale,width/4,height/4),points=[],fill=[],border=[];
45
+ for(const [x,y,start] of [[width/2-radius,height/2-radius,0],[-width/2+radius,height/2-radius,90],[-width/2+radius,-height/2+radius,180],[width/2-radius,-height/2+radius,270]])
46
+ for(let i=0;i<=5;i++){const angle=(start+i*18)*Math.PI/180;points.push([x+Math.cos(angle)*radius,y+Math.sin(angle)*radius]);}
47
+ for(let i=0;i<points.length;i++){const a=points[i],b=points[(i+1)%points.length];fill.push(0,0,0,...a,0,...b,0);border.push(...a,0,...b,0);}
48
+ visual.fill.geometry.dispose();visual.border.geometry.dispose();visual.fill.geometry=new this.G.BufferGeometry().setAttribute('position',new this.G.Float32BufferAttribute(fill,3));visual.border.geometry=new this.G.BufferGeometry().setAttribute('position',new this.G.Float32BufferAttribute(border,3));
49
+ }
50
+ visual.fill.renderOrder=-200+depth;visual.border.renderOrder=-100+depth;visual.baseBackground=this.backgroundFor(node.parent);visual.fillTint=visual.baseBackground.clone().lerp(new this.G.Color(color),.14);
51
+ visual.fill.material.color.copy(visual.fillTint);visual.border.material.color.set(color).lerp(new this.G.Color(this.dark?'#e9f0f5':'#254253'),this.dark ? .65 : .48);
52
+ }
53
+ backgroundFor(id,seen=new Set()){
54
+ if(!id||!this.index.has(id)||seen.has(id))return new this.G.Color(this.config.background||(this.dark?'#2d2f31':'#f5f5f5'));
55
+ if(this.backgrounds.has(id))return this.backgrounds.get(id).clone();seen.add(id);
56
+ const node=this.atlas.nodes.get(id),color=this.atlas.modelData.palette[node.c]||node.c||'#5eaa9d',base=this.backgroundFor(node.parent,seen).lerp(new this.G.Color(color),.14);
57
+ this.backgrounds.set(id,base);return base.clone();
58
+ }
59
+
60
+ update(layout,config){
61
+ this.layout=layout;this.scale=layout.scale;this.config=config;this.dark=config.theme==='dark';this.index=new Map();this.positions=new Map();this.backgrounds=new Map();
62
+ for(const entry of layout.items){const node=this.atlas.nodes.get(entry.id);if(!node)continue;this.index.set(entry.id,{...entry,kind:'item'});this.positions.set(entry.id,new this.G.Vector3(entry.x/this.scale,-entry.y/this.scale,10));}
63
+ for(const entry of layout.containers){this.index.set(entry.id,{...entry,kind:'container'});this.positions.set(entry.id,new this.G.Vector3((entry.minX+entry.maxX)/(2*this.scale),-(entry.minY+entry.maxY)/(2*this.scale),0));}
64
+ for(const [id,entry] of this.index){
65
+ const node=this.atlas.nodes.get(id);if(!node)continue;
66
+ if(!this.visuals.has(id))this.visuals.set(id,{});const visual=this.visuals.get(id);node.group.position.copy(this.positions.get(id));
67
+ const open=entry.kind==='container',box=entry.bounds;
68
+ const color=this.atlas.modelData.palette[node.c]||node.c||'#5eaa9d';
69
+ if(isContainer(node)){
70
+ const width=open?(entry.maxX-entry.minX)/this.scale:box.maxX-box.minX,height=open?(entry.maxY-entry.minY)/this.scale:box.maxY-box.minY;
71
+ this.container(node,visual,width,height,color,entry.depth||0);
72
+ }
73
+ const lines=open?[node.name||node.id]:entry.labelLines;
74
+ const font=open?layout.containerFontSize:entry.labelFontSize,lineHeight=open?font*1.3:entry.labelLineHeight;
75
+ const caption=this.caption(node,lines,font,lineHeight,this.dark?'#e9f0f5':'#172c3a',open?'left':'center'),[width,height]=caption.userData.size;
76
+ if(open)caption.position.set(-(entry.maxX-entry.minX)/(2*this.scale)+8/this.scale+width/2,(entry.maxY-entry.minY)/(2*this.scale)-6/this.scale-height/2,30);
77
+ else caption.position.set(0,box.minY-(entry.labelGap/this.scale)-height/2,30);
78
+ }
79
+ this.buildLinks(layout.relations);this.apply();
80
+ }
81
+ buildLinks(links){
82
+ this.links=links;this.atlas._clearRelationGroup();
83
+ const positions=[],colors=[],arrows=[],arrowColors=[],base=new this.G.Color(this.dark?'#b1c7d3':'#58788d'),background=new this.G.Color(this.dark?'#2d2f31':'#f5f5f5');
84
+ const segment=(a,b,color)=>{positions.push(a.x/this.scale,-a.y/this.scale,2,b.x/this.scale,-b.y/this.scale,2);colors.push(color.r,color.g,color.b,color.r,color.g,color.b);};
85
+ const arrow=(from,to,color)=>{
86
+ const dx=to.x-from.x,dy=to.y-from.y,length=Math.hypot(dx,dy)||1,s=Math.min(9,length*.25),x=to.x-dx/length*s,y=to.y-dy/length*s;
87
+ for(const point of [to,{x:x-dy/length*s*.42,y:y+dx/length*s*.42},{x:x+dy/length*s*.42,y:y-dx/length*s*.42}]){arrows.push(point.x/this.scale,-point.y/this.scale,2);arrowColors.push(color.r,color.g,color.b);}
88
+ };
89
+ for(const link of links){
90
+ const selected=link.index===this.atlas.selectedRelationIndex,color=link.mode==='broken'?new this.G.Color('#c43748'):selected?new this.G.Color('#008fcb'):base.clone();color.lerp(background,1-link.opacity);
91
+ if(link.mode==='broken'){for(let i=0;i<12;i+=2){const at=t=>({x:link.start.x+(link.end.x-link.start.x)*t,y:link.start.y+(link.end.y-link.start.y)*t});segment(at(i/12),at((i+1)/12),color);}}
92
+ else {segment(link.start,link.end,color);arrow(link.start,link.end,color);if(link.mode==='bidirectional')arrow(link.end,link.start,color);}
93
+ }
94
+ if(positions.length){const geometry=new this.G.BufferGeometry().setAttribute('position',new this.G.Float32BufferAttribute(positions,3)).setAttribute('color',new this.G.Float32BufferAttribute(colors,3));const mesh=new this.G.LineSegments(geometry,new this.G.LineBasicMaterial({vertexColors:true,transparent:true,depthTest:false,depthWrite:false,toneMapped:false}));mesh.renderOrder=-50;this.atlas.relationGroup.add(mesh);}
95
+ if(arrows.length){const geometry=new this.G.BufferGeometry().setAttribute('position',new this.G.Float32BufferAttribute(arrows,3)).setAttribute('color',new this.G.Float32BufferAttribute(arrowColors,3));const mesh=new this.G.Mesh(geometry,new this.G.MeshBasicMaterial({vertexColors:true,side:2,transparent:true,depthTest:false,depthWrite:false,toneMapped:false}));mesh.renderOrder=-49;this.atlas.relationGroup.add(mesh);}
96
+ }
97
+ apply(){
98
+ for(const node of this.atlas.nodes.values()){
99
+ const entry=this.index.get(node.id),visible=!!entry&&entry.visible;
100
+ node.group.visible=visible;node.label.visible=false;
101
+ if(!entry)continue;node.group.position.copy(this.positions.get(node.id));
102
+ if(isContainer(node)){node.mesh.visible=false;if(node.edge)node.edge.visible=false;const visual=this.visuals.get(node.id);visual.fill.visible=visible;visual.fill.material.color.copy(visual.fillTint).lerp(visual.baseBackground,1-entry.opacity);visual.border.visible=visible;visual.border.material.opacity=.72*entry.opacity;}
103
+ else{node.mesh.visible=visible;if(node.edge?.material)node.edge.material.opacity=(node===this.atlas.selected?1:.65)*entry.opacity;for(const material of node.appearanceMaterials||[node.material])if(material)material.opacity=(material.userData.atlasBaseOpacity??1)*entry.opacity;}
104
+ const visual=this.visuals.get(node.id);
105
+ if(node===this.atlas.selected&&!visual.selection){
106
+ const geometry=new this.G.BufferGeometry().setAttribute('position',new this.G.Float32BufferAttribute([-.5,-.5,0,.5,-.5,0,.5,-.5,0,.5,.5,0,.5,.5,0,-.5,.5,0,-.5,.5,0,-.5,-.5,0],3));
107
+ visual.selection=new this.G.LineSegments(geometry,new this.G.LineBasicMaterial({color:'#008fcb',depthTest:false,depthWrite:false,toneMapped:false}));visual.selection.renderOrder=90;node.group.add(visual.selection);
108
+ }
109
+ if(visual.selection){visual.selection.visible=visible&&node===this.atlas.selected;const box=entry.kind==='container'?{minX:-(entry.maxX-entry.minX)/(2*this.scale),maxX:(entry.maxX-entry.minX)/(2*this.scale),minY:-(entry.maxY-entry.minY)/(2*this.scale),maxY:(entry.maxY-entry.minY)/(2*this.scale)}:entry.bounds;visual.selection.scale.set(box.maxX-box.minX+.15,box.maxY-box.minY+.15,1);visual.selection.position.set((box.minX+box.maxX)/2,(box.minY+box.maxY)/2,20);}
110
+ if(visual?.caption){visual.caption.visible=visible&&(entry.kind==='container'||this.config.labels);visual.caption.material.opacity=entry.opacity;}
111
+ }
112
+ }
113
+ pick(event,fallback){
114
+ const rect=this.atlas.canvas.getBoundingClientRect(),units=28/(Math.max(.001,this.atlas.zoom)*rect.height),point={x:this.atlas.focus.x+(event.clientX-rect.left-rect.width/2)*units,y:this.atlas.focus.y-(event.clientY-rect.top-rect.height/2)*units};
115
+ const captions=[...this.visuals.entries()].filter(([id])=>this.index.get(id)?.visible&&this.visuals.get(id).caption?.visible).map(([,v])=>v.caption);
116
+ const text=this.atlas.raycaster.intersectObjects(captions,false)[0];if(text)return {kind:'entity',node:text.object.userData.node};
117
+ if(fallback?.kind==='entity'&&!isContainer(fallback.node))return fallback;
118
+ const x=point.x*this.scale,y=-point.y*this.scale;let nearest=null,distance=5*units*this.scale;
119
+ for(const link of this.links){const a=link.start,b=link.end,dx=b.x-a.x,dy=b.y-a.y,t=Math.max(0,Math.min(1,((x-a.x)*dx+(y-a.y)*dy)/(dx*dx+dy*dy||1))),d=Math.hypot(x-a.x-t*dx,y-a.y-t*dy);if(d<distance){distance=d;nearest=link;}}
120
+ if(nearest)return {kind:'relation',index:nearest.index};
121
+ let chosen=null,area=Infinity;
122
+ for(const [id,entry] of this.index){if(!entry.visible||!isContainer(this.atlas.nodes.get(id)))continue;const box=entry.kind==='container'?entry:{minX:entry.x+entry.bounds.minX*this.scale,maxX:entry.x+entry.bounds.maxX*this.scale,minY:entry.y-entry.bounds.maxY*this.scale,maxY:entry.y-entry.bounds.minY*this.scale};const size=(box.maxX-box.minX)*(box.maxY-box.minY);if(x>=box.minX&&x<=box.maxX&&y>=box.minY&&y<=box.maxY&&size<area){chosen=id;area=size;}}
123
+ return chosen?{kind:'entity',node:this.atlas.nodes.get(chosen)}:null;
124
+ }
125
+ }
@@ -185,31 +185,27 @@ function rayBoundary(item, towards) {
185
185
  }
186
186
 
187
187
  function separateOverlaps(items, gap) {
188
- for (let iteration = 0; iteration < 96; iteration += 1) {
189
- let moved = false;
190
- for (let i = 0; i < items.length; i += 1) for (let j = i + 1; j < items.length; j += 1) {
191
- const a = items[i];
192
- const b = items[j];
193
- const boundsA = itemBounds(a, a.labelLines.length > 0);
194
- const boundsB = itemBounds(b, b.labelLines.length > 0);
195
- const x = Math.min(boundsA.maxX, boundsB.maxX) - Math.max(boundsA.minX, boundsB.minX) + gap;
196
- const y = Math.min(boundsA.maxY, boundsB.maxY) - Math.max(boundsA.minY, boundsB.minY) + gap;
197
- if (x <= 0 || y <= 0) continue;
198
- moved = true;
199
- if (x < y) {
200
- const push = x / 2 + .01;
201
- const sign = a.x <= b.x ? 1 : -1;
202
- a.x -= sign * push;
203
- b.x += sign * push;
204
- } else {
205
- const push = y / 2 + .01;
206
- const sign = a.y <= b.y ? 1 : -1;
207
- a.y -= sign * push;
208
- b.y += sign * push;
209
- }
210
- }
211
- if (!moved) break;
188
+ const ordered = items.map(item => ({ item, box: itemBounds(item, item.labelLines.length > 0) }))
189
+ .sort((a,b) => a.box.minY-b.box.minY || a.box.minX-b.box.minX);
190
+ const cell = Math.max(1, ...ordered.map(({box}) => Math.max(box.maxX-box.minX,box.maxY-box.minY)+gap));
191
+ const cells = new Map();
192
+ const keys = box => {
193
+ const out=[];
194
+ for(let x=Math.floor(box.minX/cell);x<=Math.floor((box.maxX+gap)/cell);x++)
195
+ for(let y=Math.floor(box.minY/cell);y<=Math.floor((box.maxY+gap)/cell);y++)out.push(x+','+y);
196
+ return out;
197
+ };
198
+ for(const {item,box} of ordered){
199
+ for(;;){
200
+ const nearby=new Set(keys(box).flatMap(key=>cells.get(key)||[]));
201
+ let shift=0;
202
+ for(const other of nearby)if(box.minX<other.maxX+gap && box.maxX+gap>other.minX && box.minY<other.maxY+gap && box.maxY+gap>other.minY)
203
+ shift=Math.max(shift,other.maxY+gap-box.minY+.01);
204
+ if(!shift)break;
205
+ item.y+=shift;box.minY+=shift;box.maxY+=shift;
212
206
  }
207
+ for(const key of keys(box)){if(!cells.has(key))cells.set(key,[]);cells.get(key).push(box);}
208
+ }
213
209
  }
214
210
 
215
211
  function labelLines(name) {
@@ -340,6 +336,7 @@ export function renderDiagramSvg(atlas, options = {}) {
340
336
  const depth = Array.isArray(options.depth) ? options.depth : [-Infinity, Infinity];
341
337
  let items = [];
342
338
  const warnings = [];
339
+ const sharedFaces = new Map();
343
340
  for (const node of atlas.nodes.values()) {
344
341
  if (include && !include.has(node.id)) continue;
345
342
  if (!isVisible(node) || (isContainer(node) && !isClosed(node))) continue;
@@ -348,7 +345,9 @@ export function renderDiagramSvg(atlas, options = {}) {
348
345
  const distance = origin.dot(forward);
349
346
  if (distance < depth[0] || distance > depth[1]) continue;
350
347
  const faceCache = options.faceCache;
351
- let face = faceCache?.has(node) ? faceCache.get(node) : frontFaces(node, independent);
348
+ const faceKey = JSON.stringify([node.geometry || "box", node.type, node.c, node.s, node.radius, node.segments, node.geometryOptions, node.geometryPaletteOverrides, node.geometry ? null : node.mesh?.geometry?.uuid, node.c ? null : node.mesh?.material?.color?.getHexString(), independent ? null : node.material?.opacity, independent ? null : node.appearanceMaterials?.map(material => material.opacity)]);
349
+ let face = faceCache?.has(node) ? faceCache.get(node) : sharedFaces.get(faceKey);
350
+ if (!face) { face = frontFaces(node, independent); sharedFaces.set(faceKey, face); }
352
351
  if (faceCache && !faceCache.has(node)) faceCache.set(node, face);
353
352
  if (!face) { warnings.push(`${node.id}: no vector front face`); continue; }
354
353
  // The 3D renderer can leave an opened shell's material translucent when
@@ -364,7 +363,7 @@ export function renderDiagramSvg(atlas, options = {}) {
364
363
  const geometryHeight = (face.bounds.maxY - face.bounds.minY) * scale;
365
364
  items.push({ id: node.id, name: node.name || "", x: origin.dot(right) * scale,
366
365
  y: -origin.dot(up) * scale, scale, depth: distance, ...face, labelLines: lines,
367
- labelFontSize, labelLineHeight, labelGap,
366
+ faceKey, labelFontSize, labelLineHeight, labelGap,
368
367
  width: geometryWidth, height: geometryHeight });
369
368
  }
370
369
  if (options.cameraFrame && items.length) {
@@ -440,67 +439,33 @@ export function renderDiagramSvg(atlas, options = {}) {
440
439
  return boxes;
441
440
  };
442
441
  let boxes = buildBoxes();
443
- const rootGroups = containers.filter(node => boxes.has(node.id) && !boxes.has(node.parent));
444
- const rootMembers = new Map(rootGroups.map(root => [root.id, items.filter(item => {
445
- let parent = atlas.nodes.get(item.id)?.parent, guard = 0;
446
- while (parent && guard++ < atlas.nodes.size) {
447
- if (parent === root.id) return true;
448
- parent = atlas.nodes.get(parent)?.parent;
449
- }
450
- return false;
451
- })]));
452
- const groupGap = labelScale ? 8 / labelScale : gap;
453
- for (let iteration = 0; iteration < 24; iteration += 1) {
454
- let moved = false;
455
- for (let i = 0; i < rootGroups.length; i += 1) for (let j = i + 1; j < rootGroups.length; j += 1) {
456
- const a = boxes.get(rootGroups[i].id), b = boxes.get(rootGroups[j].id);
457
- const overlapX = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) + groupGap;
458
- const overlapY = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) + groupGap;
459
- if (overlapX <= 0 || overlapY <= 0) continue;
460
- const axis = overlapX < overlapY ? "x" : "y";
461
- const centerA = (a[axis === "x" ? "minX" : "minY"] + a[axis === "x" ? "maxX" : "maxY"]) / 2;
462
- const centerB = (b[axis === "x" ? "minX" : "minY"] + b[axis === "x" ? "maxX" : "maxY"]) / 2;
463
- const push = (axis === "x" ? overlapX : overlapY) / 2 + .01;
464
- const sign = centerA <= centerB ? 1 : -1;
465
- for (const item of rootMembers.get(rootGroups[i].id)) item[axis] -= sign * push;
466
- for (const item of rootMembers.get(rootGroups[j].id)) item[axis] += sign * push;
467
- boxes = buildBoxes();
468
- moved = true;
469
- }
470
- if (!moved) break;
471
- }
472
- const grouped = new Set([...rootMembers.values()].flat());
473
- const blocks = rootGroups.map(root => ({ box: boxes.get(root.id), members: rootMembers.get(root.id) }));
474
- for (const item of items) if (!grouped.has(item)) {
475
- blocks.push({ box: itemBounds(item, options.labels !== false), members: [item] });
476
- }
477
- // Keep the canonical front's authored grouping. A turned camera can spread
478
- // those same root groups across a very large page, so arrange the already
479
- // resolved group rectangles into a compact reading surface.
480
- const placement = forward.z < .97 ? compactRootBlocks(blocks, groupGap,
481
- Math.max(.5, Math.min(3, Number(options.cameraFrame?.aspect) || 1.7))) : null;
482
- if (placement) {
483
- for (const block of blocks) {
484
- const position = placement.get(block);
485
- for (const item of block.members) {
486
- item.x += position.x - block.box.minX;
487
- item.y += position.y - block.box.minY;
488
- }
489
- }
490
- boxes = buildBoxes();
491
- }
492
- // Captured diagrams keep the user's chosen 2D arrangement independently of
493
- // later changes to the packing heuristic or viewport aspect.
494
- if (options.placements && typeof options.placements === "object") {
495
- for (const item of items) {
496
- const position = options.placements[item.id];
497
- if (Array.isArray(position) && position.length === 2 && position.every(Number.isFinite)) {
498
- item.x = position[0];
499
- item.y = position[1];
500
- }
501
- }
502
- boxes = buildBoxes();
503
- }
442
+ const groupGap = labelScale ? 8 / labelScale : Math.max(8, gap);
443
+ // Resolve from the deepest siblings outwards. Each child container moves as a
444
+ // rigid subtree; parents are then measured again around their final children.
445
+ if (options.placements && typeof options.placements === "object") for(const item of items){
446
+ const at=options.placements[item.id];if(Array.isArray(at)&&at.length===2&&at.every(Number.isFinite)){item.x=at[0];item.y=at[1];}
447
+ }
448
+ boxes=buildBoxes();
449
+ const descendants=new Map(), parentOf=id=>{
450
+ let parent=atlas.nodes.get(id)?.parent,seen=new Set();
451
+ while(parent&&!seen.has(parent)){if(boxes.has(parent))return parent;seen.add(parent);parent=atlas.nodes.get(parent)?.parent;}
452
+ return null;
453
+ };
454
+ for(const item of items){let parent=parentOf(item.id),seen=new Set();while(parent&&!seen.has(parent)){seen.add(parent);if(!descendants.has(parent))descendants.set(parent,[]);descendants.get(parent).push(item);parent=parentOf(parent);}}
455
+ const parents=[...containers.filter(node=>boxes.has(node.id)).sort((a,b)=>containerDepth(b)-containerDepth(a)).map(node=>node.id),null];
456
+ for(const parent of parents){
457
+ const units=[...containers.filter(node=>boxes.has(node.id)&&parentOf(node.id)===parent).map(node=>({box:boxes.get(node.id),members:descendants.get(node.id)||[]})),...items.filter(item=>parentOf(item.id)===parent).map(item=>({box:itemBounds(item,options.labels!==!1),members:[item]}))];
458
+ let collision=false;
459
+ for(let i=0;i<units.length&&!collision;i++)for(let j=i+1;j<units.length;j++){const a=units[i].box,b=units[j].box;if(a.minX<b.maxX+groupGap&&a.maxX+groupGap>b.minX&&a.minY<b.maxY+groupGap&&a.maxY+groupGap>b.minY){collision=true;break;}}
460
+ if(!collision)continue;
461
+ const originX=Math.min(...units.map(u=>u.box.minX)),originY=Math.min(...units.map(u=>u.box.minY)),aspect=Math.max(.5,Math.min(3,Number(options.cameraFrame?.aspect)||1.7));
462
+ const area=units.reduce((sum,u)=>sum+(u.box.maxX-u.box.minX+groupGap)*(u.box.maxY-u.box.minY+groupGap),0),rowWidth=Math.max(Math.sqrt(area*aspect),...units.map(u=>u.box.maxX-u.box.minX));
463
+ units.sort((a,b)=>a.box.minY-b.box.minY||a.box.minX-b.box.minX);
464
+ let x=0,y=0,height=0;
465
+ for(const unit of units){const width=unit.box.maxX-unit.box.minX,h=unit.box.maxY-unit.box.minY;if(x&&x+width>rowWidth){x=0;y+=height+groupGap;height=0;}const dx=originX+x-unit.box.minX,dy=originY+y-unit.box.minY;for(const item of unit.members){item.x+=dx;item.y+=dy;}x+=width+groupGap;height=Math.max(height,h);}
466
+ boxes=buildBoxes();
467
+ }
468
+
504
469
  const extent = [...items.map(item => itemBounds(item, options.labels !== false)), ...boxes.values()];
505
470
  const left = (extent.length ? Math.min(...extent.map(box => box.minX)) : 0) - padding;
506
471
  const top = (extent.length ? Math.min(...extent.map(box => box.minY)) : 0) - padding;
@@ -508,6 +473,12 @@ export function renderDiagramSvg(atlas, options = {}) {
508
473
  const bottom = (extent.length ? Math.max(...extent.map(box => box.maxY)) : 0) + padding;
509
474
  const width = Math.max(1, Math.ceil(rightEdge - left));
510
475
  const height = Math.max(1, Math.ceil(bottom - top));
476
+ if (options.layoutOnly) return {
477
+ width, height, scale, left, top, labelFontSize, labelLineHeight, labelGap, containerFontSize,
478
+ items: items.map(item => ({ id:item.id, x:item.x-left, y:item.y-top, width:item.width, height:item.height, bounds:item.bounds, labelLines:item.labelLines, labelFontSize:item.labelFontSize, labelLineHeight:item.labelLineHeight, labelGap:item.labelGap, visible:!hiddenByFocus(item.id), opacity:dimmed(item.id) })),
479
+ containers: [...boxes.values()].map(box => ({ id:box.node.id, minX:box.minX-left, maxX:box.maxX-left, minY:box.minY-top, maxY:box.maxY-top, depth:containerDepth(box.node), visible:!hiddenByFocus(box.node.id), opacity:dimmed(box.node.id) })),
480
+ relations: visible.map(relation => { const from=byId.get(relation.from),to=byId.get(relation.to); return { id:relation.id, index:atlas.relations.indexOf(relation), from:relation.from, to:relation.to, start:rayBoundary(from,to), end:rayBoundary(to,from), mode:relationMode(relation), opacity:Math.min(dimmed(relation.from),dimmed(relation.to)) }; }).map(link => ({...link,start:{x:link.start.x-left,y:link.start.y-top},end:{x:link.end.x-left,y:link.end.y-top}})), warnings
481
+ };
511
482
  if (options.measureOnly) return { width, height, warnings };
512
483
  const fmt = p => `${round(p.x - left)} ${round(p.y - top)}`;
513
484
  const relations = visible.map(relation => {
@@ -554,13 +525,22 @@ export function renderDiagramSvg(atlas, options = {}) {
554
525
  ? `<rect x="${x}" y="${y}" width="${round(box.maxX - box.minX)}" height="${round(box.maxY - box.minY)}" rx="12" fill="${parentBackdrop}"/>` : "";
555
526
  return `${underlay}<g data-container="${escapeXml(box.node.id)}"${dimmed(box.node.id) < 1 ? ` opacity="${dimmed(box.node.id)}"` : ""}><title>${escapeXml(box.node.name || box.node.id)}</title><rect x="${x}" y="${y}" width="${round(box.maxX - box.minX)}" height="${round(box.maxY - box.minY)}" rx="12" ${fill} stroke="${ink}" stroke-opacity="0.72" stroke-width="2"/><g data-diagram-label="container" transform="translate(${x} ${y})"><text x="${labelX}" y="${labelY}" font-family="system-ui,sans-serif" font-size="${round(containerFontSize)}" font-weight="600" fill="${ink}">${escapeXml(box.node.name || box.node.id)}</text></g></g>`;
556
527
  }).join("");
528
+ const svgSymbols = new Map(), svgDefs = [];
529
+ const svgGlyph = (item, make) => {
530
+ if (!svgSymbols.has(item.faceKey)) {
531
+ const id = 'atlas-geometry-' + svgSymbols.size;
532
+ svgDefs.push(`<symbol id="${id}" overflow="visible">${make()}</symbol>`);
533
+ svgSymbols.set(item.faceKey, `<use href="#${id}"/>`);
534
+ }
535
+ return svgSymbols.get(item.faceKey);
536
+ };
557
537
  const entities = items.sort((a, b) => a.depth - b.depth).map(item => {
558
538
  if (hiddenByFocus(item.id)) return "";
559
539
  let parent = atlas.nodes.get(item.id)?.parent, guard = 0;
560
540
  while (parent && !boxBackdrops.has(parent) && guard++ < atlas.nodes.size) parent = atlas.nodes.get(parent)?.parent;
561
541
  const mask = options.maskGrid && !boxBackdrops.has(parent) && item.hull.length > 2
562
542
  ? `<polygon points="${item.hull.map(point => `${round(point.x * scale)},${round(-point.y * scale)}`).join(" ")}" fill="${escapeXml(canvasColor)}"/>` : "";
563
- const paths = [...item.triangles, ...item.textured].sort((a, b) => a.depth - b.depth).map((triangle, index) => {
543
+ const paths = svgGlyph(item, () => [...item.triangles, ...item.textured].sort((a, b) => a.depth - b.depth).map((triangle, index) => {
564
544
  if (triangle.image) {
565
545
  const clipId = `atlas-clip-${escapeXml(item.id).replace(/[^a-zA-Z0-9_-]/g, "-")}-${index}`;
566
546
  const hull = convexHull(triangle.points.map(point => ({ x: point.x, y: point.y })));
@@ -571,14 +551,14 @@ export function renderDiagramSvg(atlas, options = {}) {
571
551
  const p = triangle.vertices.map(point => `${round(point.x * scale)},${round(-point.y * scale)}`).join(" ");
572
552
  const stroke = triangle.opacity < 1 ? "" : ` stroke="${triangle.color}" stroke-width="0.8" stroke-linejoin="round"`;
573
553
  return `<polygon points="${p}" fill="${triangle.color}"${stroke}${triangle.opacity < 1 ? ` opacity="${round(triangle.opacity)}"` : ""}/>`;
574
- }).join("");
554
+ }).join(""));
575
555
  const labelBottom = round(-item.bounds.minY * scale);
576
556
  const label = options.labels === false || !item.labelLines.length ? "" : `<g data-diagram-label="entity" transform="translate(0 ${labelBottom})">${item.labelLines.map((line, i) => `<text x="0" y="${round(item.labelGap + item.labelFontSize + i * item.labelLineHeight)}" text-anchor="middle" font-family="system-ui,sans-serif" font-size="${round(item.labelFontSize)}" fill="${escapeXml(options.labelColor || "#172c3a")}">${escapeXml(line)}</text>`).join("")}</g>`;
577
557
  const transform = `translate(${round(item.x - left)} ${round(item.y - top)})`;
578
558
  return `${mask ? `<g transform="${transform}" pointer-events="none">${mask}</g>` : ""}<g data-entity="${escapeXml(item.id)}" transform="${transform}"${dimmed(item.id) < 1 ? ` opacity="${dimmed(item.id)}"` : ""}><title>${escapeXml(item.name || item.id)}</title>${paths}${label}</g>`;
579
559
  }).join("");
580
560
  const background = options.background ? `<rect width="100%" height="100%" fill="${escapeXml(options.background)}"/>` : "";
581
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><defs><marker id="atlas-diagram-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10Z" fill="${escapeXml(options.connectionColor || "#58788d")}"/></marker></defs>${background}<g class="containers">${containersSvg}</g><g class="relations">${relations}</g><g class="entities">${entities}</g></svg>`;
561
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><defs>${svgDefs.join('')}<marker id="atlas-diagram-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10Z" fill="${escapeXml(options.connectionColor || "#58788d")}"/></marker></defs>${background}<g class="containers">${containersSvg}</g><g class="relations">${relations}</g><g class="entities">${entities}</g></svg>`;
582
562
  return { svg, width, height, warnings, items: items.map(({ id, x, y, width: w, height: h }) => ({ id, x: round(x - left), y: round(y - top), width: round(w), height: round(h) })) };
583
563
  }
584
564
 
@@ -601,3 +581,6 @@ export async function diagramSvgToPng(svg, ownerDocument = globalThis.document)
601
581
  URL.revokeObjectURL(url);
602
582
  }
603
583
  }
584
+
585
+ /** Calculate the shared diagram without generating SVG or touching the DOM. */
586
+ export function computeDiagramLayout(atlas, options = {}) { return renderDiagramSvg(atlas, {...options, layoutOnly:true}); }
@@ -94,6 +94,7 @@ function diagramNodeVisible(atlas, node) {
94
94
  }
95
95
 
96
96
  function modelCenter(atlas) {
97
+ if (atlas._canonicalDiagramCenter) return atlas._canonicalDiagramCenter();
97
98
  const points = [...atlas.nodes.values()].filter(node => diagramNodeVisible(atlas, node) && diagramItemNode(atlas, node)).map(node => node.group.position);
98
99
  if (!points.length) return [0, 0, 0];
99
100
  return ["x", "y", "z"].map(axis => (Math.min(...points.map(point => point[axis])) + Math.max(...points.map(point => point[axis]))) / 2);
@@ -322,6 +323,9 @@ export class DiagramView {
322
323
  this.cube.hidden = !this.config.showOrientationCube;
323
324
  this.cube.innerHTML = orientationCubeSvg(cameraAngles(this.config.cameraView));
324
325
  toolbar.querySelector("[data-diagram-cube-visible]").checked = this.config.showOrientationCube;
326
+ const controlsKey = JSON.stringify([this.atlas._diagramRevision, [...this.atlas.open], this.config.includeIds, this.config.orbitId]);
327
+ if (this._itemsControlsKey !== controlsKey) {
328
+ this._itemsControlsKey = controlsKey;
325
329
  const orbit = toolbar.querySelector("[data-diagram-orbit]");
326
330
  const orbitIds = [...this.atlas.nodes.values()].filter(node => diagramNodeVisible(this.atlas, node) && diagramItemNode(this.atlas, node));
327
331
  orbit.innerHTML = '<option value="">Model center</option>' + orbitIds.map(node =>
@@ -332,6 +336,7 @@ export class DiagramView {
332
336
  const visible = new Set(this.config.includeIds || [...this.atlas.nodes.keys()]);
333
337
  const nodes = [...this.atlas.nodes.values()].filter(node => diagramNodeVisible(this.atlas, node) && diagramItemNode(this.atlas, node));
334
338
  items.innerHTML = nodes.map(node => `<button type="button" class="toolbar-option checkbox" data-diagram-item="${escapeHtml(node.id)}"><input type="checkbox" ${visible.has(node.id) ? "checked" : ""}><span>${escapeHtml(node.name || node.id)}</span></button>`).join("");
339
+ }
335
340
  const groupIds = [...new Set(this.atlas.relations.map(relation => relation.group || "default"))];
336
341
  const selectedGroups = new Set(this.config.relationGroups || groupIds);
337
342
  for (const button of toolbar.querySelectorAll('[data-action="relation-group-toggle"]')) {
@@ -439,7 +444,7 @@ export class DiagramView {
439
444
  this.atlas.toolbar.addEventListener("change", event => {
440
445
  if (!event.target.matches("[data-diagram-orbit]")) return;
441
446
  const orbitId = event.target.value;
442
- const target = orbitId ? this.atlas.nodes.get(orbitId)?.group.position.toArray() : modelCenter(this.atlas);
447
+ const target = orbitId ? (this.atlas._diagramOrbitTarget?.(orbitId) || this.atlas.nodes.get(orbitId)?.group.position.toArray()) : modelCenter(this.atlas);
443
448
  const angles = cameraAngles(this.config.cameraView);
444
449
  this.setConfig({ orbitId, cameraView: cameraView(target || modelCenter(this.atlas), angles.yaw, angles.pitch) }, { layout: true });
445
450
  });
@@ -0,0 +1,166 @@
1
+ import {DiagramScene} from './diagram-scene.js';
2
+ // Orthographic viewport over an independently calculated diagram; the domain/3D coordinates stay canonical.
3
+ const container=node=>['container','shell'].includes(node.type);
4
+ export function orthographicFit(bounds,aspect,halfHeight=14){
5
+ const width=Math.max(1,bounds.maxX-bounds.minX),height=Math.max(1,bounds.maxY-bounds.minY);
6
+ return Math.max(.001,Math.min(6,Math.min(halfHeight*2/height,halfHeight*2*aspect/width)/1.15));
7
+ }
8
+ export function diagramViewForModel(value,center,orbit){
9
+ const next={theme:'light',...value},valid=v=>Array.isArray(v)&&v.length===3&&v.every(Number.isFinite),view=next.cameraView;
10
+ if(!view||view.projection==='orthographic'||!valid(view.position)||!valid(view.target))next.cameraView={target:[...center],position:[center[0],center[1],center[2]+30]};
11
+ else if(orbit||valid(next.cameraModelCenter)){
12
+ const target=orbit||view.target.map((n,i)=>n+center[i]-next.cameraModelCenter[i]),delta=target.map((n,i)=>n-view.target[i]);
13
+ next.cameraView={target:[...target],position:view.position.map((n,i)=>n+delta[i])};
14
+ }
15
+ next.cameraModelCenter=[...center];return next;
16
+ }
17
+ export function installPlanarAtlas(Atlas,OrthographicCamera,computeLayout,G){
18
+ const proto=Atlas.prototype;if(proto._planarInstalled)return;proto._planarInstalled=true;
19
+ const original=Object.fromEntries(['_build','_worldPosition','getCameraView','setCameraView','_panByPixels','fit','frontView','resize','_positionJumpButton','_renderFrame','exportDiagramSvg','_rebuildRelations','_geometryContentUpdated','_pick','captureFrame','zoomIn','zoomOut','focusSelection'].map(key=>[key,proto[key]]));
20
+ proto._setupPlanar=function(){
21
+ if(this._planarSetup)return;this._planarSetup=true;
22
+ const style=this.ownerDocument.createElement('style');
23
+ style.textContent=`:host([data-planar="true"]) .viewport>canvas{visibility:visible!important;pointer-events:auto!important}
24
+ :host([data-planar="true"]) .diagram-view{display:none!important}
25
+ :host([data-planar="true"]) [data-toolbar-group="camera"]{display:none!important}
26
+ :host([data-planar="true"]) [data-toolbar-group="copy"]{display:flex!important}
27
+ .diagram-toolbar .toolbar-popover{max-height:min(420px,calc(100dvh - 160px),var(--lc-menu-height,420px));overflow-y:auto}
28
+ .diagram-appearance{padding:6px 9px;display:grid;gap:8px;color:#dbe7ec;font-size:12px}.diagram-appearance label{display:flex;gap:8px;align-items:center}`;
29
+ this.shadowRoot.append(style);
30
+ const diagram=this.diagramView,setState=diagram.setState.bind(diagram),action=diagram.action.bind(diagram);
31
+ diagram.setState=(value={})=>setState(diagramViewForModel(value,this._canonicalDiagramCenter(),value.orbitId?this._diagramOrbitTarget(value.orbitId):null));
32
+ diagram.refresh=()=>{if(!this._webglPlanar)return false;this._scheduleDiagram();return true;};
33
+ diagram.scheduleRefresh=()=>{if(this._webglPlanar)this._scheduleDiagram();};
34
+ diagram.focusSelection=()=>this.focusSelection();
35
+ diagram.action=(name,control)=>{
36
+ if(name==='diagram-plus'){this.zoomIn();return true;}if(name==='diagram-minus'){this.zoomOut();return true;}if(name==='diagram-fit'){this.fit();return true;}
37
+ return action(name,control);
38
+ };
39
+ const options=this.toolbar.querySelector('[data-toolbar-menu="diagram-options"] .toolbar-popover');
40
+ options?.insertAdjacentHTML('beforeend','<div class="diagram-appearance"><label><input type="checkbox" data-diagram-light checked>Fondo claro</label><label><input type="checkbox" data-diagram-grid checked>Cuadrícula de puntos</label></div>');
41
+ this.toolbar.addEventListener('change',event=>{
42
+ if(event.target.matches('[data-diagram-light]'))diagram.setConfig({theme:event.target.checked?'light':'dark'});
43
+ if(event.target.matches('[data-diagram-grid]'))diagram.setConfig({grid:event.target.checked});
44
+ });
45
+ this.addEventListener('lc-disconnect',()=>{cancelAnimationFrame(this._planarChangeFrame);cancelAnimationFrame(this._diagramFrame);});
46
+ };
47
+ proto._canonicalDiagramCenter=function(){
48
+ const points=[...this.nodes.values()].filter(n=>this._isVisible(n)&&(!container(n)||!this.open.has(n.id))).map(n=>this._canonicalPositions?.get(n.id)||original._worldPosition.call(this,n));
49
+ if(!points.length)return [0,0,0];return ['x','y','z'].map(axis=>(Math.min(...points.map(p=>p[axis]))+Math.max(...points.map(p=>p[axis])))/2);
50
+ };
51
+ proto._diagramOrbitTarget=function(id){return this._canonicalPositions?.get(id)?.toArray();};
52
+ proto._withCanonicalDiagram=function(run){
53
+ this._computingDiagram=true;
54
+ const visibility=new Map();for(const node of this.nodes.values()){node.group.position.copy(this._canonicalPositions.get(node.id));visibility.set(node.id,node.mesh.visible);node.mesh.visible=true;}
55
+ try{return run();}finally{for(const node of this.nodes.values())node.mesh.visible=visibility.get(node.id);this._computingDiagram=false;if(this._diagramScene)for(const [id,position] of this._diagramScene.positions)this.nodes.get(id)?.group.position.copy(position);}
56
+ };
57
+ proto._worldPosition=function(node,trail){
58
+ const position=this._webglPlanar&&!this._computingDiagram?this._diagramScene?.positions.get(node?.id):null;
59
+ return position?position.clone():original._worldPosition.call(this,node,trail);
60
+ };
61
+ proto._configurePlanarCamera=function(planar){
62
+ this._webglPlanar=planar;this.dataset.planar=String(planar);this.dataset.renderer=planar?'webgl-orthographic':'webgl-perspective';
63
+ if(planar){if(!this.camera.isOrthographicCamera){this._perspectiveCamera=this.camera;this.camera=new OrthographicCamera(-14,14,14,-14,.1,20000);this.camera.aspect=1;}this.yaw=0;this.pitch=0;}
64
+ else {if(this._perspectiveCamera){this.camera=this._perspectiveCamera;this._perspectiveCamera=null;}const viewport=this.shadowRoot.querySelector('.viewport');viewport.style.backgroundColor='';viewport.style.backgroundImage='';viewport.style.backgroundSize='';viewport.style.backgroundPosition='';this.shadowRoot.querySelector('.hint').style.color='';}
65
+ };
66
+ proto._build=function(options={}){
67
+ // Detached/headless callers may build geometry for vector export without a DOM renderer.
68
+ if(!this.ownerDocument)return original._build.call(this,options);
69
+ if(!this._ensureRenderer())return;
70
+ this._setupPlanar();this._configurePlanarCamera(this.viewMode==='2d');
71
+ cancelAnimationFrame(this._diagramFrame);this._diagramFrame=0;this._diagramScene=null;this._diagramLayout=null;this._diagramKey=null;this._diagramFaces=new Map();this._canonicalPositions=new Map();this._buildingDiagram=true;
72
+ try{original._build.call(this,options);}finally{this._buildingDiagram=false;}
73
+ for(const node of this.nodes.values())this._canonicalPositions.set(node.id,original._worldPosition.call(this,node));
74
+ this._diagramRevision=(this._diagramRevision||0)+1;
75
+ if(this._webglPlanar){this.diagramView.setState(this._diagramRevision===1?(this.modelData.render?.views?.['2d']||{}):this.diagramView.config);this._diagramScene=new DiagramScene(this,G);this._diagramScene.flattenMaterials();this._diagramFitted=true;this._runDiagram();}
76
+ };
77
+ proto._scheduleDiagram=function(){
78
+ if(this._buildingDiagram||this._diagramFrame||!this._webglPlanar)return;
79
+ this._diagramFrame=requestAnimationFrame(()=>{this._diagramFrame=0;if(this.isConnected&&this.ready&&this._webglPlanar)this._runDiagram();});
80
+ };
81
+ proto._diagramOptions=function(){
82
+ const config=this.diagramView.config,rect=this.canvas.getBoundingClientRect();
83
+ return {...this.diagramView.renderOptions(),cameraView:config.cameraView,cameraFrame:{zoom:config.cameraZoom,aspect:rect.width/Math.max(1,rect.height)},observerScale:1,faceCache:this._diagramFaces,background:null,maskGrid:true};
84
+ };
85
+ proto._runDiagram=function(){
86
+ if(!this._diagramScene||!this.nodes.size)return;
87
+ const start=performance.now(),options=this._diagramOptions(),key=JSON.stringify([this._diagramRevision,[...this.open],options,this.selectedRelationIndex]);
88
+ const cubeChanged=this._lastDiagramCube!==this.diagramView.config.showOrientationCube;this._lastDiagramCube=this.diagramView.config.showOrientationCube;
89
+ this.diagramView.cube.hidden=!this.diagramView.config.showOrientationCube;
90
+ if(key!==this._diagramKey){
91
+ const layout=this._withCanonicalDiagram(()=>computeLayout(this,options));
92
+ this._diagramScene.update(layout,this.diagramView.config);this._diagramLayout=layout;this._diagramKey=key;
93
+ this.dataset.layoutVersion=String((Number(this.dataset.layoutVersion)||0)+1);this.dataset.layoutMs=(performance.now()-start).toFixed(2);this.dataset.diagramItems=String(layout.items.filter(i=>i.visible).length);
94
+ if(this._diagramFitted)this.fit({animate:false});
95
+ }
96
+ if(this._diagramToolbarRevision!==this._diagramRevision){this._syncRelationToolbar();this._diagramToolbarRevision=this._diagramRevision;}
97
+ this.diagramView._syncControls();
98
+ this.toolbar.querySelector('[data-diagram-camera-zoom]').value=String(Math.round(this.diagramView.config.cameraZoom*100));
99
+ const light=this.toolbar.querySelector('[data-diagram-light]'),grid=this.toolbar.querySelector('[data-diagram-grid]');if(light)light.checked=this.diagramView.config.theme!=='dark';if(grid)grid.checked=this.diagramView.config.grid;
100
+ if(cubeChanged&&this._diagramFitted)this.fit({animate:false});
101
+ this._applyDiagramTheme();this.invalidate();
102
+ };
103
+ proto._applyDiagramTheme=function(){
104
+ const config=this.diagramView.config,dark=config.theme==='dark',viewport=this.shadowRoot.querySelector('.viewport');
105
+ viewport.style.backgroundColor=config.background||(dark?'#2d2f31':'#f5f5f5');viewport.style.backgroundImage=config.grid?`radial-gradient(circle,${dark?'#73777c':'#b3b3b3'} .8px,transparent 1px)`:'none';viewport.style.backgroundSize='16px 16px';
106
+ this.shadowRoot.querySelector('.hint').style.color=dark?'#b1c7d3':'#527286';
107
+ };
108
+ proto._applyDiagramPresentation=function(){this._diagramScene?.apply();};
109
+ proto.setViewMode=function(mode,options={}){
110
+ this._setupPlanar();if(!['2d','3d'].includes(mode))throw new TypeError('Atlas view mode must be 2d or 3d.');if(!this._ensureRenderer())return this;
111
+ const changed=this.viewMode!==mode;
112
+ if(changed&&this.ready){this._modeCameras??={};this._modeCameras[this.viewMode]=this.getCameraView();}
113
+ this.viewMode=mode;this._configurePlanarCamera(mode==='2d');this.dataset.viewMode=mode;this.diagramView.root.hidden=true;
114
+ this.canvas.setAttribute('aria-label',mode==='2d'?'Diagrama 2D de arquitectura':'Modelo 3D de arquitectura');
115
+ const button=this.toolbar.querySelector('[data-action="view-mode"]');button.textContent=mode==='2d'?'3D':'2D';button.setAttribute('aria-label',mode==='2d'?'Switch to 3D model':'Switch to 2D diagram');button.title=button.getAttribute('aria-label');
116
+ const facing=this.toolbar.querySelector('[data-action="item-facing"]');if(facing)facing.disabled=mode==='2d';
117
+ this.shadowRoot.querySelector('.hint').textContent=mode==='2d'?'Desplazar: arrastrar · Zoom: rueda o + / − · Orientación y filtros: barra y cubo':'Rotate: drag · Pan: ⇧+drag · Zoom: wheel · Select: click';
118
+ const complete=()=>{
119
+ if(!this.isConnected||!this.ready||this.viewMode!==mode)return;
120
+ if(this._projectedPlanar!==(mode==='2d'))this._build({preserveSelection:this.selectedId,preserveRelation:this.selectedRelationIndex});
121
+ this.resize();if(changed||options.force){const saved=this._modeCameras?.[mode];if(saved)this.setCameraView(saved,{animate:false});else this.fit({animate:false});}
122
+ if(mode==='2d')this._runDiagram();else {this._syncRelationToolbar();this._syncFocusToolbar();this._syncLabelsToolbar();}
123
+ this.invalidate();
124
+ };
125
+ this._projectedPlanar??=mode==='2d';
126
+ if(changed){cancelAnimationFrame(this._planarChangeFrame);this._planarChangeFrame=requestAnimationFrame(complete);}else complete();
127
+ if(changed&&options.emit!==false)this._emit('lc-view-mode-change',{mode,source:options.source||'api'});return this;
128
+ };
129
+ // Remember which representation is built (not just which mode the toolbar requested).
130
+ const build=proto._build;proto._build=function(options){build.call(this,options);this._projectedPlanar=this._webglPlanar;};
131
+ proto._rebuildRelations=function(){if(this._webglPlanar){if(!this._buildingDiagram)this._scheduleDiagram();return;}return original._rebuildRelations.call(this);};
132
+ proto._geometryContentUpdated=function(){if(this._webglPlanar){this._diagramFaces?.clear();this._diagramKey=null;this._scheduleDiagram();}else original._geometryContentUpdated.call(this);};
133
+ proto._updatePlanarCamera=function(){const camera=this.camera;camera.zoom=Math.max(.001,this.zoom);camera.position.set(this.focus.x,this.focus.y,1000);camera.lookAt(this.focus.x,this.focus.y,0);camera.updateProjectionMatrix();};
134
+ proto.resize=function(){
135
+ if(this._webglPlanar){const rect=this.canvas.getBoundingClientRect();this.camera.aspect=rect.width/Math.max(1,rect.height);this.camera.left=-14*this.camera.aspect;this.camera.right=14*this.camera.aspect;this.camera.top=14;this.camera.bottom=-14;}
136
+ return original.resize.call(this);
137
+ };
138
+ proto.getCameraView=function(){return this._webglPlanar?{position:[this.focus.x,this.focus.y,1000],target:[this.focus.x,this.focus.y,0],zoom:this.zoom,projection:'orthographic'}:original.getCameraView.call(this);};
139
+ proto.setCameraView=function(view={},options={}){
140
+ if(!this._webglPlanar)return original.setCameraView.call(this,view,options);
141
+ this._diagramFitted=false;
142
+ if(view.target?.length===3&&view.target.every(Number.isFinite))this.targetFocus.set(view.target[0],view.target[1],0);if(Number.isFinite(view.zoom)&&view.zoom>0)this.targetZoom=Math.max(.001,Math.min(6,view.zoom));
143
+ if(options.animate===false){this.focus.copy(this.targetFocus);this.zoom=this.targetZoom;}
144
+ this.invalidate();return this;
145
+ };
146
+ proto._panByPixels=function(dx,dy){if(!this._webglPlanar)return original._panByPixels.call(this,dx,dy);this._diagramFitted=false;const units=28/(Math.max(.001,this.zoom)*Math.max(1,this.canvas.getBoundingClientRect().height));this.targetFocus.x-=dx*units;this.targetFocus.y+=dy*units;this.invalidate();return this;};
147
+ proto.focusSelection=function(options={}){if(this._webglPlanar)this._diagramFitted=false;return original.focusSelection.call(this,options);};
148
+ proto.zoomIn=function(){if(this._webglPlanar)this._diagramFitted=false;return original.zoomIn.call(this);};
149
+ proto.zoomOut=function(){if(!this._webglPlanar)return original.zoomOut.call(this);this._diagramFitted=false;this.targetZoom=Math.max(.001,this.targetZoom/1.18);this.invalidate();return this;};
150
+ proto.fit=function(options={}){
151
+ if(!this._webglPlanar)return original.fit.call(this,options);if(!this._diagramLayout)return this;
152
+ this._diagramFitted=true;const layout=this._diagramLayout,scale=layout.scale,rect=this.canvas.getBoundingClientRect(),left=this.diagramView.config.showOrientationCube?132:16,right=16,top=34,bottom=this.toolbarVisible?this.toolbar.offsetHeight+44:16;
153
+ this.targetZoom=orthographicFit({minX:0,maxX:layout.width/scale,minY:0,maxY:layout.height/scale},this.camera.aspect||1)*Math.max(.1,Math.min((rect.width-left-right)/rect.width,(rect.height-top-bottom)/rect.height));
154
+ const units=28/(this.targetZoom*Math.max(1,rect.height));this.targetFocus.set(layout.width/(2*scale)-(left-right)*units/2,-layout.height/(2*scale)+(top-bottom)*units/2,0);
155
+ if(options.animate===false){this.focus.copy(this.targetFocus);this.zoom=this.targetZoom;}this.invalidate();return this;
156
+ };
157
+ proto.frontView=function(){if(!this._webglPlanar)return original.frontView.call(this);this.diagramView.action('diagram-front');return this;};
158
+ proto._pick=function(event){if(!this._webglPlanar)return original._pick.call(this,event);const mode=this.relationMode;let hit;this.relationMode='none';try{hit=original._pick.call(this,event);}finally{this.relationMode=mode;}return this._diagramScene?.pick(event,hit)||hit;};
159
+ proto._positionJumpButton=function(){if(!this._webglPlanar)return original._positionJumpButton.call(this);const mode=this.viewMode;this.viewMode='3d';try{return original._positionJumpButton.call(this);}finally{this.viewMode=mode;}};
160
+ proto.getDiagramView=function(){const state=this.diagramView.captureState();return {...state,cameraModelCenter:this._canonicalDiagramCenter(),viewport:this._webglPlanar?{...this.getCameraView(),fitted:this._diagramFitted}:state.viewport};};
161
+ proto.captureDiagramView=function(){return this.getDiagramView();};
162
+ proto.setDiagramView=function(state={}){this.diagramView.setState(state);if(this._webglPlanar&&state.viewport?.projection==='orthographic'){this._diagramFitted=false;this.setCameraView(state.viewport,{animate:false});this._diagramFitted=state.viewport.fitted===true;}return this;};
163
+ proto._renderFrame=function(time){const start=performance.now(),result=original._renderFrame.call(this,time);if(this._webglPlanar){this.dataset.frameMs=(performance.now()-start).toFixed(2);this.dataset.frames=String((Number(this.dataset.frames)||0)+1);this.dataset.drawCalls=String(this.renderer?.info.render.calls||0);}return result;};
164
+ proto.captureFrame=function(options={}){return original.captureFrame.call(this,this._webglPlanar&&options.background==='theme'?{...options,background:this.diagramView.config.background||(this.diagramView.config.theme==='dark'?'#2d2f31':'#f5f5f5')}:options);};
165
+ proto.exportDiagramSvg=function(options={}){this.dataset.svgExports=String((Number(this.dataset.svgExports)||0)+1);if(this._webglPlanar)return this._withCanonicalDiagram(()=>original.exportDiagramSvg.call(this,{...this._diagramOptions(),...options}));return original.exportDiagramSvg.call(this,options);};
166
+ }