@liquidcars/atlas-layout 0.1.10 → 0.1.14

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
@@ -49,6 +49,11 @@ como alias de compatibilidad. Un contenedor puede no tener geometría o usar una
49
49
  primitiva `geo.*`; las geometrías personalizadas de los packs, como `infra.cloud`,
50
50
  sólo se permiten en entidades hoja.
51
51
 
52
+ Un contenedor puede declarar `open: true` para comenzar expandido. El estado
53
+ cerrado es el valor predeterminado, por lo que `open: false` se normaliza
54
+ omitiendo la propiedad. Declarar `open` en una hoja o usar un valor que no sea
55
+ booleano produce un diagnóstico fatal.
56
+
52
57
  La presentación puede conservar una vista inicial sin introducir datos de
53
58
  cámara en el algoritmo de layout:
54
59
 
@@ -113,6 +118,10 @@ const resolved = compileAtlasYaml(yamlText);
113
118
 
114
119
  La API principal recibe un objeto JavaScript ya parseado. Se aceptan entidades anidadas mediante `children` o entidades planas con referencias `parent`; internamente ambas formas se normalizan al contrato plano. Las entradas de texto se manejan mediante los subpaths `/yaml` y `/build` durante el build.
115
120
 
121
+ El entrypoint principal exporta `ATLAS_LAYOUT_VERSION`, obtenido del manifest
122
+ del paquete cargado. Las aplicaciones anfitrionas pueden compararlo con la
123
+ versión declarada en sus propias dependencias.
124
+
116
125
  En Markdown se acepta front matter YAML, un bloque completo ` ```atlas ` o bloques separados ` ```atlas-model `, ` ```atlas-layout `, ` ```atlas-render ` y ` ```atlas-relations `. Por ejemplo:
117
126
 
118
127
  ````markdown
@@ -196,4 +205,67 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
196
205
 
197
206
  El espacio de una celda y el tamaño de la geometría son conceptos distintos. Cuando el layout asigna una celda rectangular a una geometría sin `size` explícito, la figura se centra y se escala uniformemente para caber en el menor volumen compatible; no se estira por separado en `x`, `y` y `z`. Por eso una esfera conserva `s[0] === s[1] === s[2]`, aunque su celda sea rectangular. Un `size` explícito sigue teniendo prioridad y permite al autor solicitar una proporción concreta.
198
207
 
199
- `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. `variant: masonry` desplaza las filas alternas media celda sin exigir una sección `stagger`; `stagger.offset` queda disponible como override explícito. Para conservar legibilidad frontal se recomienda `plane: xy`, y para una composición lateral, `plane: yz`. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría. El algoritmo `graph` sigue reservado: por ahora emite `GRAPH_LAYOUT_FALLBACK` y usa `grid`. La proyección y el enrutado avanzado de relaciones entre contenedores permanecen como líneas de desarrollo posteriores.
208
+ `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. `variant: masonry` desplaza las filas alternas media celda sin exigir una sección `stagger`; `stagger.offset` queda disponible como override explícito. Para conservar legibilidad frontal se recomienda `plane: xy`, y para una composición lateral, `plane: yz`. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría.
209
+
210
+ `auto` delega en el optimizador global la elección de algoritmo, dirección y
211
+ plano para ese propietario de layout. El compilador prueba alternativas de
212
+ `graph`, `row`, `grid` y `volume` mediante una búsqueda determinista y puntúa el modelo
213
+ completo, no cada contenedor de forma aislada. La búsqueda resuelve primero los
214
+ contenidos anidados y después sus propietarios, de modo que la composición global
215
+ se decide con tamaños y afinidades estables. `graph` también aporta variantes
216
+ espaciales internas con varios carriles de profundidad; `auto` puede elegirlas
217
+ sin que el autor tenga que fijar un nuevo algoritmo. Las intersecciones físicas
218
+ 3D y los solapamientos son condiciones estrictas. Entre las soluciones válidas,
219
+ la función perceptiva equilibra los atravesamientos y cruces proyectados con la
220
+ legibilidad frontal, la alineación ortogonal, la longitud media de las relaciones,
221
+ la compacidad volumétrica, la coherencia de orientación y las proporciones
222
+ excesivamente alargadas. Los carriles de profundidad automáticos emplean un paso
223
+ compacto; un layout `graph` explícito conserva el paso completo. Un `direction`, `plane`,
224
+ `gap` o `padding` declarado junto a `auto` queda bloqueado y limita las
225
+ alternativas. Si no se declara `gap`, se utiliza `3`.
226
+
227
+ ```yaml
228
+ layout:
229
+ algorithm: auto
230
+ # direction: z # opcional: bloquea el eje, pero no el algoritmo resuelto
231
+ ```
232
+
233
+ La cámara inicial de `render.camera` es la vista principal de la puntuación. Se
234
+ combina con una vista frontal canónica —que mide cuántos frentes quedan ocultos—
235
+ y varias perspectivas oblicuas para evitar una solución que solo resulte legible
236
+ desde un ángulo. Un host como
237
+ Atlas Studio también puede pasar la vista interactiva con
238
+ `compileAtlasModel(source, { autoLayoutCamera: camera })`. Cada propietario
239
+ automático produce un diagnóstico informativo `AUTO_LAYOUT_RESOLVED` con la
240
+ configuración elegida y las métricas globales, incluidas la oclusión frontal,
241
+ las peores métricas proyectadas y las intersecciones espaciales. `analyzeAtlasLayout(model,
242
+ { camera })` permite puntuar un modelo ya compilado con la misma función
243
+ objetivo.
244
+
245
+ `graph` implementa un layout por capas sensible a las relaciones. Proyecta las
246
+ relaciones de descendientes sobre los hijos inmediatos del contenedor, asigna
247
+ capas según la dirección del flujo y utiliza barridos de baricentro para reducir
248
+ cruces y longitud de conexiones. Los empates conservan el orden de autoría para
249
+ que el resultado sea determinista. Las relaciones con `layout: false` no
250
+ participan. Los ciclos se rompen de forma estable y producen el diagnóstico
251
+ `GRAPH_CYCLE_STABILIZED`. Cuando varios contenedores usan `graph`, una pasada
252
+ jerárquica adicional utiliza las relaciones que cruzan sus límites para alinear
253
+ carriles entre contenedores. De este modo, dos elementos relacionados pueden
254
+ quedar en la misma vertical u horizontal aunque no sean hermanos directos.
255
+
256
+ El `gap` predeterminado de `graph` es `3`; puede reducirse de forma explícita
257
+ para modelos compactos. Las relaciones siguen determinando las capas sobre
258
+ `direction`. Elegir automáticamente entre composiciones alternativas en `x`,
259
+ `y` o `z` pertenece a una fase de optimización asistida, no al compilador
260
+ determinista.
261
+
262
+ ```yaml
263
+ layout:
264
+ algorithm: graph
265
+ direction: y
266
+ plane: xy
267
+ gap: 3
268
+ ```
269
+
270
+ El enrutado avanzado de tubos alrededor de geometrías permanece como una fase
271
+ posterior e independiente de la colocación de nodos.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liquidcars/atlas-layout",
3
- "version": "0.1.10",
3
+ "version": "0.1.14",
4
4
  "description": "Declarative layout compiler for LiquidCars Atlas models",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -47,7 +47,7 @@ subject to sizeMode and diagnostics.
47
47
 
48
48
  Required: id.
49
49
 
50
- Common fields: name, sub, text, url, type, parent, children, geometry, geometryOptions,
50
+ Common fields: name, sub, text, url, type, open, parent, children, geometry, geometryOptions,
51
51
  geometryPalette, style, layout, weight, span, size, sizeMode, p, s, c and radius.
52
52
 
53
53
  An entity with children is a container and must explicitly declare `type: container`.
@@ -55,6 +55,9 @@ A leaf entity may omit `type`, in which case it is normalized as an item. The
55
55
  legacy value `type: shell` remains accepted as a compatibility alias for
56
56
  `container`, but is not emitted by the compiler or suggested by the editor.
57
57
 
58
+ Only a container may declare `open`. `open: true` makes it initially expanded;
59
+ omitting the property (or declaring `false`) means initially closed.
60
+
58
61
  Containers may omit geometry or use a built-in primitive such as `geo.box`.
59
62
  Custom geometry-pack geometries such as `infra.cloud` and `media.image-box` are
60
63
  leaf geometries and cannot be used on containers. The compiler reports these violations
@@ -71,7 +74,37 @@ fit permits assisted layout to use the geometry's canonical size.
71
74
 
72
75
  ## Assisted layout
73
76
 
74
- layout.algorithm accepts row, grid, volume or graph. direction accepts x, y or z.
77
+ layout.algorithm accepts auto, row, grid, volume or graph. direction accepts x,
78
+ y or z. Auto is an explicit permission for the compiler to evaluate graph, row,
79
+ grid, volume and internal spatial-graph alternatives against the whole model.
80
+ Real 3D relation/item intersections and visible traversals have first-class
81
+ priority. Front-face occlusion is measured from the canonical front view; the
82
+ primary and canonical oblique views then contribute relation crossings,
83
+ projected overlap, sibling orientation consistency, elongation, relation length
84
+ and footprint to the deterministic score. An authored direction, plane,
85
+ gap or padding beside auto is locked and narrows the search. Auto uses gap 3
86
+ when none is authored. The selected concrete layout is reported through
87
+ `AUTO_LAYOUT_RESOLVED`; it does not replace `algorithm: auto` in the authored
88
+ source.
89
+
90
+ The initial render.camera is the primary projection used for scoring and is
91
+ combined with the canonical front view and canonical oblique views so the result
92
+ does not overfit one angle. A host may instead supply the current interactive view as the
93
+ `autoLayoutCamera` compile option. `analyzeAtlasLayout` exposes the same metrics
94
+ for already compiled models.
95
+
96
+ Graph uses a deterministic layered layout driven by relations whose `layout`
97
+ field is not false. Relations between descendants are projected onto the
98
+ immediate children of the container being laid out. Atlas orders each layer to
99
+ reduce crossings and edge length while retaining authored order as the stable
100
+ tie-breaker. When two or more graph-layout owners are linked by relations, a
101
+ hierarchical sweep also projects those external relations into normalized lane
102
+ hints. This aligns related children across container boundaries without adding
103
+ authored constraints. When `auto` has not been limited to an authored plane, it
104
+ may also distribute graph nodes across deterministic depth lanes. Directed cycles are broken deterministically and reported
105
+ as `GRAPH_CYCLE_STABILIZED`. Graph uses a default gap of 3 world units; the
106
+ other algorithms retain their existing defaults. Axis and plane selection stay
107
+ declarative and are not changed implicitly by compilation.
75
108
  Grid layouts also accept variant uniform or masonry, optional columns and rows, and
76
109
  a plane of xz (the default), xy or yz. Masonry offsets alternate rows by half a cell
77
110
  unless `stagger.offset` explicitly overrides that distance. Use plane xy for layouts
package/src/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { ATLAS_LAYOUT_SPEC_V1 } from "./spec.js";
2
+ import atlasLayoutPackage from "../package.json" with { type: "json" };
3
+
4
+ // Consumers can compare this value with their declared dependency to verify
5
+ // the exact layout module that is executing.
6
+ export const ATLAS_LAYOUT_VERSION = atlasLayoutPackage.version;
2
7
 
3
8
  const DEFAULT_COLOR = "#b9d8eb";
4
9
 
@@ -142,6 +147,18 @@ function normalizeEntityTypes(flat, geometryCatalog, diagnostics) {
142
147
  const type = hasChildren || declared === "container" || declared === "shell" ? "container" : "item";
143
148
  entity.type = type;
144
149
 
150
+ if (Object.hasOwn(source, "open") && typeof source.open !== "boolean") {
151
+ diagnostic(diagnostics, "fatal", "INVALID_CONTAINER_OPEN_STATE",
152
+ "Entity '" + entity.id + "' has a non-boolean open value. Use true or omit the property.",
153
+ { entityId: entity.id, open: source.open });
154
+ } else if (Object.hasOwn(source, "open") && type !== "container") {
155
+ diagnostic(diagnostics, "fatal", "OPEN_STATE_REQUIRES_CONTAINER",
156
+ "Entity '" + entity.id + "' declares open but is not a container.",
157
+ { entityId: entity.id, open: source.open });
158
+ }
159
+ if (type === "container" && source.open === true) entity.open = true;
160
+ else delete entity.open;
161
+
145
162
  if (type === "container" && source.geometry && !isPrimitiveGeometry(source.geometry, geometryCatalog)) {
146
163
  diagnostic(diagnostics, "fatal", "CUSTOM_GEOMETRY_ON_CONTAINER",
147
164
  "Container '" + entity.id + "' cannot use non-primitive geometry '" + source.geometry + "'.",
@@ -567,20 +584,20 @@ function compactRowBands(placements, constraints, spec, lockedAxes = new Map())
567
584
  }
568
585
  }
569
586
 
570
- function layoutFunction(spec, diagnostics, ownerId) {
587
+ function layoutFunction(spec, diagnostics, ownerId, graphContext = null) {
571
588
  const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
572
589
  if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
573
590
  `Unknown layout algorithm '${spec?.algorithm}', using grid.`, { entityId: ownerId });
574
- if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK",
575
- `Graph layout for '${ownerId || "root"}' is reserved for a later compiler pass; using grid for now.`, { entityId: ownerId });
576
591
  return algorithm === "row"
577
592
  ? rowLayout
578
593
  : algorithm === "volume"
579
594
  ? volumeLayout
580
- : gridLayout;
595
+ : algorithm === "graph"
596
+ ? (items, layoutSpec, layoutDiagnostics) => graphLayout(items, layoutSpec, layoutDiagnostics, graphContext, ownerId)
597
+ : gridLayout;
581
598
  }
582
599
 
583
- function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
600
+ function materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext = null) {
584
601
  const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
585
602
  if (!definitions.length) return { items, groups: new Map() };
586
603
 
@@ -636,7 +653,9 @@ function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
636
653
  definition,
637
654
  diagnostics,
638
655
  definition.id,
639
- layoutFunction(groupSpec, diagnostics, definition.id)
656
+ layoutFunction(groupSpec, diagnostics, definition.id, graphContext),
657
+ null,
658
+ graphContext
640
659
  );
641
660
  const virtual = {
642
661
  entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
@@ -702,8 +721,8 @@ function flattenLayoutGroups(placements) {
702
721
  return flattened;
703
722
  }
704
723
 
705
- function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
706
- const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId);
724
+ function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null, graphContext = null) {
725
+ const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext);
707
726
  const constraints = layoutConstraints(source, spec);
708
727
  applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
709
728
  reflowLayoutGroups(grouped.items, diagnostics);
@@ -956,6 +975,180 @@ function volumeLayout(items, spec, diagnostics) {
956
975
  return packedGridLayout(items, spec, diagnostics, 3);
957
976
  }
958
977
 
978
+ function graphAxes(spec = {}) {
979
+ const main = { x: 0, y: 1, z: 2 }[spec.direction] ?? 1;
980
+ const plane = GRID_PLANES[spec.plane] || (main === 2 ? GRID_PLANES.xz : GRID_PLANES.xy);
981
+ const cross = plane.find(axis => axis !== main) ?? [0, 1, 2].find(axis => axis !== main);
982
+ const depth = [0, 1, 2].find(axis => axis !== main && axis !== cross);
983
+ return { main, cross, depth };
984
+ }
985
+
986
+ function graphEndpointItem(endpointId, itemIds, directItemById, byId) {
987
+ let cursor = byId?.get(endpointId);
988
+ const seen = new Set();
989
+ while (cursor && !seen.has(cursor.id)) {
990
+ seen.add(cursor.id);
991
+ const direct = directItemById.get(cursor.id);
992
+ if (direct && itemIds.has(direct)) return direct;
993
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
994
+ }
995
+ return null;
996
+ }
997
+
998
+ function graphLayout(items, spec, diagnostics, context = null, ownerId = null) {
999
+ const gap = Math.max(0, scalar(spec.gap, 3));
1000
+ const padding = Math.max(0, scalar(spec.padding, 1));
1001
+ if (!items.length) return boundsOf([], padding);
1002
+
1003
+ const ids = items.map(item => item.entity.id);
1004
+ const itemIds = new Set(ids);
1005
+ const authoredIndex = new Map(ids.map((id, index) => [id, index]));
1006
+ const directItemById = new Map();
1007
+ for (const item of items) {
1008
+ directItemById.set(item.entity.id, item.entity.id);
1009
+ if (item.entity.__atlasVirtualGroup) {
1010
+ for (const member of item.memberItems || []) directItemById.set(member.entity.id, item.entity.id);
1011
+ }
1012
+ }
1013
+
1014
+ const edgeWeights = new Map();
1015
+ for (const relation of context?.relations || []) {
1016
+ if (relation.layout === false) continue;
1017
+ const from = graphEndpointItem(relation.from, itemIds, directItemById, context.byId);
1018
+ const to = graphEndpointItem(relation.to, itemIds, directItemById, context.byId);
1019
+ if (!from || !to || from === to) continue;
1020
+ const key = `${from}\u0000${to}`;
1021
+ edgeWeights.set(key, (edgeWeights.get(key) || 0) + positiveNumber(relation.priority, 1));
1022
+ }
1023
+ const edges = [...edgeWeights].map(([key, weight]) => {
1024
+ const [from, to] = key.split("\u0000");
1025
+ return { from, to, weight };
1026
+ });
1027
+
1028
+ const predecessors = new Map(ids.map(id => [id, []]));
1029
+ const successors = new Map(ids.map(id => [id, []]));
1030
+ const indegree = new Map(ids.map(id => [id, 0]));
1031
+ for (const edge of edges) {
1032
+ predecessors.get(edge.to).push(edge);
1033
+ successors.get(edge.from).push(edge);
1034
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
1035
+ }
1036
+
1037
+ const layerById = new Map(ids.map(id => [id, 0]));
1038
+ const processed = new Set();
1039
+ let cycleReported = false;
1040
+ while (processed.size < ids.length) {
1041
+ let available = ids.filter(id => !processed.has(id) && indegree.get(id) === 0);
1042
+ if (!available.length) {
1043
+ available = ids.filter(id => !processed.has(id)).slice(0, 1);
1044
+ if (!cycleReported) {
1045
+ cycleReported = true;
1046
+ diagnostic(diagnostics, "warning", "GRAPH_CYCLE_STABILIZED",
1047
+ "Graph layout contains a directed cycle; authored order was used to break it deterministically.");
1048
+ }
1049
+ }
1050
+ available.sort((left, right) => authoredIndex.get(left) - authoredIndex.get(right));
1051
+ const id = available[0];
1052
+ const resolvedPredecessors = predecessors.get(id).filter(edge => processed.has(edge.from));
1053
+ layerById.set(id, resolvedPredecessors.length
1054
+ ? Math.max(...resolvedPredecessors.map(edge => layerById.get(edge.from) + 1))
1055
+ : 0);
1056
+ processed.add(id);
1057
+ for (const edge of successors.get(id)) indegree.set(edge.to, Math.max(0, indegree.get(edge.to) - 1));
1058
+ }
1059
+
1060
+ const layers = [];
1061
+ for (const id of ids) {
1062
+ const layer = layerById.get(id);
1063
+ layers[layer] ||= [];
1064
+ layers[layer].push(id);
1065
+ }
1066
+ const order = new Map();
1067
+ const updateOrder = () => layers.forEach(layer => layer.forEach((id, index) => order.set(id, index)));
1068
+ const ownerHints = context?.graphOrderHints?.get(ownerId ?? null);
1069
+ const reorder = (layer, neighbors) => layer.sort((left, right) => {
1070
+ const score = id => {
1071
+ const related = neighbors.get(id).filter(edge => order.has(edge.from === id ? edge.to : edge.from));
1072
+ const external = ownerHints?.get(id);
1073
+ if (!related.length && !external) return null;
1074
+ const internalWeight = related.reduce((sum, edge) => sum + edge.weight, 0);
1075
+ const internalScore = related.reduce((sum, edge) => {
1076
+ const other = edge.from === id ? edge.to : edge.from;
1077
+ const otherLayer = layers[layerById.get(other)] || [];
1078
+ const normalizedOrder = otherLayer.length > 1
1079
+ ? order.get(other) / (otherLayer.length - 1)
1080
+ : .5;
1081
+ return sum + normalizedOrder * Math.max(0, layer.length - 1) * edge.weight;
1082
+ }, 0);
1083
+ const externalWeight = external?.weight || 0;
1084
+ const externalScore = (external?.rank || 0) * Math.max(0, layer.length - 1);
1085
+ const totalWeight = internalWeight + externalWeight;
1086
+ return totalWeight ? (internalScore + externalScore * externalWeight) / totalWeight : null;
1087
+ };
1088
+ const leftScore = score(left);
1089
+ const rightScore = score(right);
1090
+ if (leftScore == null && rightScore == null) return authoredIndex.get(left) - authoredIndex.get(right);
1091
+ if (leftScore == null) return 1;
1092
+ if (rightScore == null) return -1;
1093
+ return leftScore - rightScore || authoredIndex.get(left) - authoredIndex.get(right);
1094
+ });
1095
+
1096
+ updateOrder();
1097
+ if (ownerHints?.size) {
1098
+ const adjacent = new Map(ids.map(id => [id, [...predecessors.get(id), ...successors.get(id)]]));
1099
+ for (const layer of layers) reorder(layer, adjacent);
1100
+ updateOrder();
1101
+ }
1102
+ for (let pass = 0; pass < 6; pass++) {
1103
+ for (let layer = 1; layer < layers.length; layer++) {
1104
+ reorder(layers[layer], predecessors);
1105
+ updateOrder();
1106
+ }
1107
+ for (let layer = layers.length - 2; layer >= 0; layer--) {
1108
+ reorder(layers[layer], successors);
1109
+ updateOrder();
1110
+ }
1111
+ }
1112
+
1113
+ const { main, cross, depth } = graphAxes(spec);
1114
+ const byItemId = new Map(items.map(item => [item.entity.id, item]));
1115
+ const layerExtents = layers.map(layer => Math.max(...layer.map(id => byItemId.get(id).size[main])));
1116
+ const mainPositions = [];
1117
+ let mainCursor = 0;
1118
+ for (let index = 0; index < layers.length; index++) {
1119
+ if (index) mainCursor += layerExtents[index - 1] / 2 + gap * 1.6 + layerExtents[index] / 2;
1120
+ mainPositions[index] = mainCursor;
1121
+ }
1122
+ const directionSign = main === 0 ? 1 : -1;
1123
+ const spatialLayers = spec.spatial === true
1124
+ ? Math.max(2, Math.min(4, Math.floor(positiveNumber(spec.depthLayers, 3))))
1125
+ : 1;
1126
+ const spatialPhase = Math.floor(scalar(spec.spatialPhase, 0));
1127
+ const depthPitch = (Math.max(...items.map(item => item.size[depth]), 1) + gap)
1128
+ * positiveNumber(spec.depthScale, 1);
1129
+ const placements = [];
1130
+ layers.forEach((layer, layerIndex) => {
1131
+ const totalCross = layer.reduce((sum, id) => sum + byItemId.get(id).size[cross], 0)
1132
+ + Math.max(0, layer.length - 1) * gap;
1133
+ let crossCursor = -totalCross / 2;
1134
+ for (let itemIndex = 0; itemIndex < layer.length; itemIndex++) {
1135
+ const id = layer[itemIndex];
1136
+ const item = byItemId.get(id);
1137
+ const position = [0, 0, 0];
1138
+ crossCursor += item.size[cross] / 2;
1139
+ position[main] = mainPositions[layerIndex] * directionSign;
1140
+ position[cross] = crossCursor;
1141
+ if (spatialLayers > 1) {
1142
+ const lane = (layerIndex + itemIndex + spatialPhase) % spatialLayers;
1143
+ position[depth] = (lane - (spatialLayers - 1) / 2) * depthPitch;
1144
+ }
1145
+ crossCursor += item.size[cross] / 2 + gap;
1146
+ placements.push({ ...item, position, graphLayer: layerIndex });
1147
+ }
1148
+ });
1149
+ return boundsOf(placements, padding);
1150
+ }
1151
+
959
1152
  function resolveTheme(source) {
960
1153
  const theme = source.theme || source.styles || {};
961
1154
  const render = source.render || {};
@@ -1046,7 +1239,602 @@ export function compileAtlasProjection(input, visibleIds, options = {}) {
1046
1239
  return compileAtlasModel(projectedAtlasSource(input, visibleIds), options);
1047
1240
  }
1048
1241
 
1049
- export function compileAtlasModel(input, options = {}) {
1242
+ function globalGraphOrderHints(source, flat, relations, byId) {
1243
+ const depthOf = id => {
1244
+ let depth = 0;
1245
+ let cursor = id ? byId.get(id) : null;
1246
+ const seen = new Set();
1247
+ while (cursor?.parent && !seen.has(cursor.id)) {
1248
+ seen.add(cursor.id);
1249
+ depth += 1;
1250
+ cursor = byId.get(cursor.parent);
1251
+ }
1252
+ return depth;
1253
+ };
1254
+ const owners = [];
1255
+ const rootChildren = flat.entities.filter(entity => !entity.parent).map(entity => entity.id);
1256
+ if (source.layout?.algorithm === "graph" && rootChildren.length) {
1257
+ owners.push({ id: null, depth: -1, children: rootChildren });
1258
+ }
1259
+ for (const entity of flat.entities) {
1260
+ const children = flat.childrenById.get(entity.id) || [];
1261
+ const layout = flat.sourceById.get(entity.id)?.layout;
1262
+ if (layout?.algorithm === "graph" && children.length) {
1263
+ owners.push({ id: entity.id, depth: depthOf(entity.id), children: [...children] });
1264
+ }
1265
+ }
1266
+ if (owners.length < 2) return new Map();
1267
+
1268
+ const immediateChild = (endpointId, owner) => {
1269
+ let cursor = byId.get(endpointId);
1270
+ const seen = new Set();
1271
+ while (cursor && !seen.has(cursor.id)) {
1272
+ seen.add(cursor.id);
1273
+ if (owner.id === null && !cursor.parent) return owner.children.includes(cursor.id) ? cursor.id : null;
1274
+ if (cursor.parent === owner.id) return owner.children.includes(cursor.id) ? cursor.id : null;
1275
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
1276
+ }
1277
+ return null;
1278
+ };
1279
+ const membership = endpointId => owners
1280
+ .map(owner => ({ owner, child: immediateChild(endpointId, owner) }))
1281
+ .filter(item => item.child)
1282
+ .sort((left, right) => right.owner.depth - left.owner.depth)[0] || null;
1283
+
1284
+ const links = new Map(owners.map(owner => [owner.id, new Map(owner.children.map(id => [id, []]))]));
1285
+ for (const relation of relations) {
1286
+ if (relation.layout === false) continue;
1287
+ const from = membership(relation.from);
1288
+ const to = membership(relation.to);
1289
+ if (!from || !to || from.owner.id === to.owner.id) continue;
1290
+ const weight = positiveNumber(relation.priority, 1);
1291
+ links.get(from.owner.id).get(from.child).push({ ownerId: to.owner.id, childId: to.child, weight });
1292
+ links.get(to.owner.id).get(to.child).push({ ownerId: from.owner.id, childId: from.child, weight });
1293
+ }
1294
+
1295
+ const authored = new Map();
1296
+ const ranks = new Map();
1297
+ for (const owner of owners) {
1298
+ const divisor = Math.max(1, owner.children.length - 1);
1299
+ authored.set(owner.id, new Map(owner.children.map((id, index) => [id, index])));
1300
+ ranks.set(owner.id, new Map(owner.children.map((id, index) => [id, index / divisor])));
1301
+ }
1302
+ const sweep = orderedOwners => {
1303
+ for (const owner of orderedOwners) {
1304
+ const ownerLinks = links.get(owner.id);
1305
+ const previousRanks = ranks.get(owner.id);
1306
+ const scored = owner.children.map(id => {
1307
+ const neighbors = ownerLinks.get(id) || [];
1308
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1309
+ const score = total
1310
+ ? neighbors.reduce((sum, neighbor) => sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total
1311
+ : previousRanks.get(id);
1312
+ return { id, score, linked: total > 0 };
1313
+ });
1314
+ scored.sort((left, right) => left.score - right.score
1315
+ || Number(right.linked) - Number(left.linked)
1316
+ || authored.get(owner.id).get(left.id) - authored.get(owner.id).get(right.id));
1317
+ const divisor = Math.max(1, scored.length - 1);
1318
+ ranks.set(owner.id, new Map(scored.map((item, index) => [item.id, index / divisor])));
1319
+ }
1320
+ };
1321
+ for (let pass = 0; pass < 8; pass += 1) {
1322
+ sweep(owners);
1323
+ sweep([...owners].reverse());
1324
+ }
1325
+
1326
+ const hints = new Map();
1327
+ for (const owner of owners) {
1328
+ const ownerHints = new Map();
1329
+ for (const childId of owner.children) {
1330
+ const neighbors = links.get(owner.id).get(childId) || [];
1331
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1332
+ if (!total) continue;
1333
+ const rank = neighbors.reduce((sum, neighbor) =>
1334
+ sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total;
1335
+ ownerHints.set(childId, { rank, weight: total * 2 });
1336
+ }
1337
+ if (ownerHints.size) hints.set(owner.id, ownerHints);
1338
+ }
1339
+ return hints;
1340
+ }
1341
+
1342
+ const AUTO_LAYOUT_DEFAULT_CAMERA = Object.freeze({
1343
+ position: [12, 8, 14],
1344
+ target: [0, 0, 0]
1345
+ });
1346
+
1347
+ function autoLayoutOwners(source) {
1348
+ const owners = [];
1349
+ const add = (owner, id, root = false) => {
1350
+ if (owner?.layout?.algorithm === "auto") owners.push({ owner, id, root, authoredLayout: structuredClone(owner.layout) });
1351
+ for (const group of owner?.layout?.groups || []) add(group, `${id ?? "root"}::group:${group.id || owners.length}`);
1352
+ };
1353
+ add(source, null, true);
1354
+ const visit = items => {
1355
+ for (const entity of Array.isArray(items) ? items : []) {
1356
+ add(entity, entity.id);
1357
+ visit(entity.children);
1358
+ }
1359
+ };
1360
+ visit(source.entities);
1361
+ return owners;
1362
+ }
1363
+
1364
+ function autoGraphPlanes(direction) {
1365
+ if (direction === "x") return ["xy", "xz"];
1366
+ if (direction === "y") return ["xy", "yz"];
1367
+ return ["xz", "yz"];
1368
+ }
1369
+
1370
+ function autoLayoutCandidates(entry) {
1371
+ const authored = entry.authoredLayout || entry.owner.layout || {};
1372
+ const common = { ...authored, gap: authored.gap ?? 3, padding: authored.padding ?? 1 };
1373
+ delete common.algorithm;
1374
+ const directions = authored.direction ? [authored.direction] : entry.root ? ["y", "x", "z"] : ["z", "x", "y"];
1375
+ const planes = authored.plane ? [authored.plane] : ["xy", "xz", "yz"];
1376
+ const candidates = [];
1377
+ for (const direction of directions) {
1378
+ const graphPlanes = authored.plane ? planes : autoGraphPlanes(direction);
1379
+ for (const plane of graphPlanes) {
1380
+ candidates.push({ ...common, algorithm: "graph", direction, plane });
1381
+ if (!authored.plane) {
1382
+ for (let spatialPhase = 0; spatialPhase < 3; spatialPhase++) {
1383
+ candidates.push({
1384
+ ...common,
1385
+ algorithm: "graph",
1386
+ direction,
1387
+ plane,
1388
+ spatial: true,
1389
+ depthLayers: 3,
1390
+ spatialPhase,
1391
+ // Automatic depth lanes are an escape route for real collisions,
1392
+ // not a reason to inflate the whole scene. Explicit graph layouts
1393
+ // retain the historical full pitch unless they opt into a scale.
1394
+ depthScale: .55
1395
+ });
1396
+ }
1397
+ }
1398
+ }
1399
+ }
1400
+ for (const direction of directions) candidates.push({ ...common, algorithm: "row", direction });
1401
+ if (!authored.direction || authored.plane) {
1402
+ for (const plane of planes) candidates.push({ ...common, algorithm: "grid", plane });
1403
+ }
1404
+ // Volume has no privileged axis or plane. Only offer it when the author has
1405
+ // not locked either of those decisions; otherwise choosing it would silently
1406
+ // ignore an explicit constraint.
1407
+ if (!authored.direction && !authored.plane) candidates.push({ ...common, algorithm: "volume" });
1408
+ const keys = new Set();
1409
+ return candidates.filter(candidate => {
1410
+ const key = JSON.stringify([
1411
+ candidate.algorithm,
1412
+ candidate.direction || "",
1413
+ candidate.plane || "",
1414
+ candidate.columns || "",
1415
+ candidate.rows || "",
1416
+ candidate.layers || "",
1417
+ candidate.spatial || false,
1418
+ candidate.depthLayers || "",
1419
+ candidate.spatialPhase || "",
1420
+ candidate.depthScale || "",
1421
+ candidate.gap,
1422
+ candidate.padding
1423
+ ]);
1424
+ if (keys.has(key)) return false;
1425
+ keys.add(key);
1426
+ return true;
1427
+ });
1428
+ }
1429
+
1430
+ function vectorSubtract(a, b) {
1431
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1432
+ }
1433
+
1434
+ function vectorDot(a, b) {
1435
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1436
+ }
1437
+
1438
+ function vectorCross(a, b) {
1439
+ return [
1440
+ a[1] * b[2] - a[2] * b[1],
1441
+ a[2] * b[0] - a[0] * b[2],
1442
+ a[0] * b[1] - a[1] * b[0]
1443
+ ];
1444
+ }
1445
+
1446
+ function vectorNormalize(value, fallback) {
1447
+ const length = Math.hypot(...value);
1448
+ return length > 1e-9 ? value.map(item => item / length) : [...fallback];
1449
+ }
1450
+
1451
+ function autoProjection(render = {}) {
1452
+ const camera = render.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1453
+ const position = asVector(camera.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1454
+ const target = asVector(camera.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1455
+ const forward = vectorNormalize(vectorSubtract(target, position), [0, 0, -1]);
1456
+ let right = vectorNormalize(vectorCross(forward, [0, 1, 0]), [1, 0, 0]);
1457
+ if (Math.abs(vectorDot(right, right)) < 1e-9) right = [1, 0, 0];
1458
+ const up = vectorNormalize(vectorCross(right, forward), [0, 1, 0]);
1459
+ return point => {
1460
+ const relative = vectorSubtract(point, target);
1461
+ return [vectorDot(relative, right), vectorDot(relative, up), vectorDot(relative, forward)];
1462
+ };
1463
+ }
1464
+
1465
+ function orientation2d(a, b, c) {
1466
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1467
+ }
1468
+
1469
+ function strictSegmentIntersection2d(a, b, c, d) {
1470
+ const abC = orientation2d(a, b, c);
1471
+ const abD = orientation2d(a, b, d);
1472
+ const cdA = orientation2d(c, d, a);
1473
+ const cdB = orientation2d(c, d, b);
1474
+ const epsilon = 1e-7;
1475
+ return ((abC > epsilon && abD < -epsilon) || (abC < -epsilon && abD > epsilon))
1476
+ && ((cdA > epsilon && cdB < -epsilon) || (cdA < -epsilon && cdB > epsilon));
1477
+ }
1478
+
1479
+ function segmentIntersectsRect2d(a, b, rect) {
1480
+ const inset = Math.min(rect.maxX - rect.minX, rect.maxY - rect.minY) * .08;
1481
+ const minX = rect.minX + inset;
1482
+ const maxX = rect.maxX - inset;
1483
+ const minY = rect.minY + inset;
1484
+ const maxY = rect.maxY - inset;
1485
+ if (minX >= maxX || minY >= maxY) return false;
1486
+ const inside = point => point[0] > minX && point[0] < maxX && point[1] > minY && point[1] < maxY;
1487
+ if (inside(a) || inside(b)) return true;
1488
+ const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]];
1489
+ return corners.some((corner, index) => strictSegmentIntersection2d(a, b, corner, corners[(index + 1) % corners.length]));
1490
+ }
1491
+
1492
+ function segmentIntersectsBox3d(a, b, box) {
1493
+ let minimum = 0;
1494
+ let maximum = 1;
1495
+ for (let axis = 0; axis < 3; axis++) {
1496
+ const delta = b[axis] - a[axis];
1497
+ if (Math.abs(delta) < 1e-9) {
1498
+ if (a[axis] <= box.min[axis] || a[axis] >= box.max[axis]) return false;
1499
+ continue;
1500
+ }
1501
+ let near = (box.min[axis] - a[axis]) / delta;
1502
+ let far = (box.max[axis] - a[axis]) / delta;
1503
+ if (near > far) [near, far] = [far, near];
1504
+ minimum = Math.max(minimum, near);
1505
+ maximum = Math.min(maximum, far);
1506
+ if (minimum >= maximum) return false;
1507
+ }
1508
+ return maximum > 1e-7 && minimum < 1 - 1e-7;
1509
+ }
1510
+
1511
+ function boxesOverlap3d(left, right) {
1512
+ return [0, 1, 2].every(axis =>
1513
+ Math.min(left.max[axis], right.max[axis]) > Math.max(left.min[axis], right.min[axis]));
1514
+ }
1515
+
1516
+ function projectedLayoutMetrics(model, world, boxes, camera) {
1517
+ const project = autoProjection({ camera });
1518
+ const points = new Map([...world].map(([id, point]) => [id, project(point)]));
1519
+ const rectangles = new Map();
1520
+ for (const [id, box] of boxes) {
1521
+ const corners = [];
1522
+ for (const x of [box.min[0], box.max[0]]) for (const y of [box.min[1], box.max[1]]) for (const z of [box.min[2], box.max[2]]) {
1523
+ corners.push(project([x, y, z]));
1524
+ }
1525
+ rectangles.set(id, {
1526
+ minX: Math.min(...corners.map(point => point[0])),
1527
+ maxX: Math.max(...corners.map(point => point[0])),
1528
+ minY: Math.min(...corners.map(point => point[1])),
1529
+ maxY: Math.max(...corners.map(point => point[1]))
1530
+ });
1531
+ }
1532
+ const segments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1533
+ const from = points.get(relation.from);
1534
+ const to = points.get(relation.to);
1535
+ return from && to ? [{ relation, from, to }] : [];
1536
+ });
1537
+ let crossings = 0;
1538
+ for (let left = 0; left < segments.length; left++) for (let right = left + 1; right < segments.length; right++) {
1539
+ const a = segments[left];
1540
+ const b = segments[right];
1541
+ if ([a.relation.from, a.relation.to].some(id => id === b.relation.from || id === b.relation.to)) continue;
1542
+ if (strictSegmentIntersection2d(a.from, a.to, b.from, b.to)) crossings += 1;
1543
+ }
1544
+ let traversals = 0;
1545
+ for (const segment of segments) {
1546
+ for (const [id, rect] of rectangles) {
1547
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1548
+ if (segmentIntersectsRect2d(segment.from, segment.to, rect)) traversals += 1;
1549
+ }
1550
+ }
1551
+ let overlaps = 0;
1552
+ let occlusion = 0;
1553
+ const rectangleEntries = [...rectangles];
1554
+ for (let left = 0; left < rectangleEntries.length; left++) for (let right = left + 1; right < rectangleEntries.length; right++) {
1555
+ const a = rectangleEntries[left][1];
1556
+ const b = rectangleEntries[right][1];
1557
+ const overlapWidth = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
1558
+ const overlapHeight = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
1559
+ if (overlapWidth > 0 && overlapHeight > 0) {
1560
+ overlaps += 1;
1561
+ const leftArea = Math.max(1e-9, (a.maxX - a.minX) * (a.maxY - a.minY));
1562
+ const rightArea = Math.max(1e-9, (b.maxX - b.minX) * (b.maxY - b.minY));
1563
+ occlusion += overlapWidth * overlapHeight / Math.min(leftArea, rightArea);
1564
+ }
1565
+ }
1566
+ const projected = [...points.values()];
1567
+ const width = projected.length ? Math.max(...projected.map(point => point[0])) - Math.min(...projected.map(point => point[0])) : 0;
1568
+ const height = projected.length ? Math.max(...projected.map(point => point[1])) - Math.min(...projected.map(point => point[1])) : 0;
1569
+ return { crossings, traversals, overlaps, occlusion, footprint: width * height };
1570
+ }
1571
+
1572
+ const AUTO_LAYOUT_FRONT_CAMERA = Object.freeze({
1573
+ position: [0, 0, 100],
1574
+ target: [0, 0, 0]
1575
+ });
1576
+
1577
+ const AUTO_LAYOUT_CANONICAL_CAMERAS = Object.freeze([
1578
+ Object.freeze({ position: [12, 8, 14], target: [0, 0, 0] }),
1579
+ Object.freeze({ position: [-12, 8, 14], target: [0, 0, 0] }),
1580
+ Object.freeze({ position: [12, 8, -14], target: [0, 0, 0] }),
1581
+ Object.freeze({ position: [-12, 8, -14], target: [0, 0, 0] })
1582
+ ]);
1583
+
1584
+ function cameraDirectionKey(camera) {
1585
+ const position = asVector(camera?.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1586
+ const target = asVector(camera?.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1587
+ return vectorNormalize(vectorSubtract(target, position), [0, 0, -1]).map(value => value.toFixed(3)).join(":");
1588
+ }
1589
+
1590
+ function autoLayoutCameras(model, options = {}) {
1591
+ const primary = options.camera || model.render?.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1592
+ const seen = new Set();
1593
+ return [{ camera: primary, weight: 1, primary: true }, ...AUTO_LAYOUT_CANONICAL_CAMERAS.map(camera => ({ camera, weight: .35, primary: false }))]
1594
+ .filter(entry => {
1595
+ const key = cameraDirectionKey(entry.camera);
1596
+ if (seen.has(key)) return false;
1597
+ seen.add(key);
1598
+ return true;
1599
+ });
1600
+ }
1601
+
1602
+ function layoutPlane(layout = {}) {
1603
+ if (["graph", "grid"].includes(layout.algorithm) && ["xy", "xz", "yz"].includes(layout.plane)) return layout.plane;
1604
+ if (layout.algorithm === "row") return { x: "yz", y: "xz", z: "xy" }[layout.direction] || null;
1605
+ return null;
1606
+ }
1607
+
1608
+ function layoutOrientationMismatches(source) {
1609
+ if (!source || typeof source !== "object") return 0;
1610
+ let mismatches = 0;
1611
+ const visit = owner => {
1612
+ const expected = layoutPlane(owner.layout);
1613
+ for (const child of Array.isArray(owner.entities) ? owner.entities : Array.isArray(owner.children) ? owner.children : []) {
1614
+ if (isContainerType(child?.type) || Array.isArray(child?.children)) {
1615
+ const actual = layoutPlane(child.layout);
1616
+ if (expected && actual && expected !== actual) mismatches += 1;
1617
+ }
1618
+ visit(child);
1619
+ }
1620
+ };
1621
+ visit(source);
1622
+ return mismatches;
1623
+ }
1624
+
1625
+ function elongationPenalty(extents) {
1626
+ const ordered = extents.map(value => Math.max(0, value)).sort((left, right) => right - left);
1627
+ const ratio = ordered[0] / Math.max(ordered[1], 1e-6);
1628
+ return { ratio, penalty: Math.max(0, ratio - 3) ** 2 };
1629
+ }
1630
+
1631
+ function median(values, fallback = 1) {
1632
+ if (!values.length) return fallback;
1633
+ const ordered = [...values].sort((left, right) => left - right);
1634
+ const middle = Math.floor(ordered.length / 2);
1635
+ return ordered.length % 2
1636
+ ? ordered[middle]
1637
+ : (ordered[middle - 1] + ordered[middle]) / 2;
1638
+ }
1639
+
1640
+ function autoLayoutMetrics(model, options = {}) {
1641
+ const byId = new Map((model.entities || []).map(entity => [entity.id, entity]));
1642
+ const world = new Map();
1643
+ const resolveWorld = id => {
1644
+ if (world.has(id)) return world.get(id);
1645
+ const entity = byId.get(id);
1646
+ if (!entity) return [0, 0, 0];
1647
+ const position = asVector(entity.p);
1648
+ const resolved = entity.parent ? add(resolveWorld(entity.parent), position) : position;
1649
+ world.set(id, resolved);
1650
+ return resolved;
1651
+ };
1652
+ for (const id of byId.keys()) resolveWorld(id);
1653
+ const boxes = new Map();
1654
+ for (const entity of model.entities || []) {
1655
+ if (isContainerType(entity.type)) continue;
1656
+ const center = world.get(entity.id);
1657
+ const size = asVector(entity.s, [1, 1, 1]);
1658
+ boxes.set(entity.id, {
1659
+ min: center.map((value, axis) => value - size[axis] * .42),
1660
+ max: center.map((value, axis) => value + size[axis] * .42)
1661
+ });
1662
+ }
1663
+ const spatialSegments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1664
+ const from = world.get(relation.from);
1665
+ const to = world.get(relation.to);
1666
+ return from && to ? [{ relation, from, to }] : [];
1667
+ });
1668
+ let spatialTraversals = 0;
1669
+ for (const segment of spatialSegments) {
1670
+ for (const [id, box] of boxes) {
1671
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1672
+ if (segmentIntersectsBox3d(segment.from, segment.to, box)) spatialTraversals += 1;
1673
+ }
1674
+ }
1675
+ let spatialOverlaps = 0;
1676
+ const boxEntries = [...boxes];
1677
+ for (let left = 0; left < boxEntries.length; left++) for (let right = left + 1; right < boxEntries.length; right++) {
1678
+ if (boxesOverlap3d(boxEntries[left][1], boxEntries[right][1])) spatialOverlaps += 1;
1679
+ }
1680
+ const views = autoLayoutCameras(model, options).map(entry => ({
1681
+ ...projectedLayoutMetrics(model, world, boxes, entry.camera),
1682
+ weight: entry.weight,
1683
+ primary: entry.primary
1684
+ }));
1685
+ const front = projectedLayoutMetrics(model, world, boxes, AUTO_LAYOUT_FRONT_CAMERA);
1686
+ const primary = views.find(view => view.primary) || views[0] || { crossings: 0, traversals: 0, overlaps: 0, footprint: 0 };
1687
+ const robustCrossings = views.reduce((sum, view) => sum + view.crossings * view.weight, 0);
1688
+ const robustTraversals = views.reduce((sum, view) => sum + view.traversals * view.weight, 0);
1689
+ const robustOverlaps = views.reduce((sum, view) => sum + view.overlaps * view.weight, 0);
1690
+ const footprint = views.reduce((sum, view) => sum + view.footprint * view.weight, 0);
1691
+ const edgeLength = spatialSegments.reduce((sum, segment) => sum + Math.hypot(
1692
+ segment.to[0] - segment.from[0],
1693
+ segment.to[1] - segment.from[1],
1694
+ segment.to[2] - segment.from[2]
1695
+ ), 0);
1696
+ const bounds = [...boxes.values()];
1697
+ const extents = bounds.length ? [0, 1, 2].map(axis =>
1698
+ Math.max(...bounds.map(box => box.max[axis])) - Math.min(...bounds.map(box => box.min[axis]))) : [0, 0, 0];
1699
+ const elongation = elongationPenalty(extents);
1700
+ const orientationMismatches = layoutOrientationMismatches(options.source);
1701
+ const characteristicSize = median([...boxes.values()].map(box => Math.hypot(
1702
+ box.max[0] - box.min[0],
1703
+ box.max[1] - box.min[1],
1704
+ box.max[2] - box.min[2]
1705
+ )));
1706
+ const relationCount = Math.max(1, spatialSegments.length);
1707
+ const normalizedEdgeLength = edgeLength / relationCount / Math.max(characteristicSize, 1e-6);
1708
+ const sceneVolume = extents.reduce((product, extent) => product * Math.max(extent, 1e-6), 1);
1709
+ const itemVolume = [...boxes.values()].reduce((sum, box) => sum
1710
+ + (box.max[0] - box.min[0])
1711
+ * (box.max[1] - box.min[1])
1712
+ * (box.max[2] - box.min[2]), 0);
1713
+ const volumeRatio = sceneVolume / Math.max(itemVolume, 1e-6);
1714
+ const compactnessPenalty = Math.cbrt(Math.max(1, volumeRatio));
1715
+ const frontProject = autoProjection({ camera: AUTO_LAYOUT_FRONT_CAMERA });
1716
+ const orthogonalDeviation = spatialSegments.reduce((sum, segment) => {
1717
+ const from = frontProject(segment.from);
1718
+ const to = frontProject(segment.to);
1719
+ return sum + Math.min(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1]))
1720
+ / Math.max(characteristicSize, 1e-6);
1721
+ }, 0) / relationCount;
1722
+ // Formal 3D validity remains effectively absolute. Once candidates are
1723
+ // physically valid, projected defects compete with the qualities people use
1724
+ // to judge a diagram: short relations, compact grouping and orthogonal flow.
1725
+ // This prevents the optimizer from "winning" by moving valid groups ever
1726
+ // farther apart merely to remove a small projected crossing.
1727
+ const aestheticScore = primary.traversals * 120
1728
+ + front.traversals * 100
1729
+ + robustTraversals * 25
1730
+ + front.overlaps * 250
1731
+ + front.occlusion * 600
1732
+ + primary.crossings * 6
1733
+ + front.crossings * 5
1734
+ + robustCrossings * 2
1735
+ + robustOverlaps * 40
1736
+ + orientationMismatches * 50
1737
+ + elongation.penalty * 30
1738
+ + normalizedEdgeLength * 80
1739
+ + compactnessPenalty * 100
1740
+ + orthogonalDeviation * 35;
1741
+ return {
1742
+ crossings: primary.crossings,
1743
+ traversals: primary.traversals,
1744
+ overlaps: primary.overlaps,
1745
+ worstCrossings: Math.max(0, ...views.map(view => view.crossings)),
1746
+ worstTraversals: Math.max(0, ...views.map(view => view.traversals)),
1747
+ frontCrossings: front.crossings,
1748
+ frontTraversals: front.traversals,
1749
+ frontOverlaps: front.overlaps,
1750
+ frontOcclusion: front.occlusion,
1751
+ spatialTraversals,
1752
+ spatialOverlaps,
1753
+ orientationMismatches,
1754
+ elongation: elongation.ratio,
1755
+ elongationPenalty: elongation.penalty,
1756
+ edgeLength,
1757
+ normalizedEdgeLength,
1758
+ sceneVolume,
1759
+ volumeRatio,
1760
+ compactnessPenalty,
1761
+ orthogonalDeviation,
1762
+ footprint,
1763
+ aestheticScore,
1764
+ views: views.map(({ weight, primary: isPrimary, ...metrics }) => ({ ...metrics, weight, primary: isPrimary })),
1765
+ score: spatialTraversals * 1e12
1766
+ + spatialOverlaps * 1e10
1767
+ + aestheticScore
1768
+ };
1769
+ }
1770
+
1771
+ function compareAutoLayoutMetrics(left, right) {
1772
+ // A line or an item may never pass through another item in real 3D space.
1773
+ // Everything after those two feasibility checks is a perceptual trade-off;
1774
+ // comparing each projected metric lexicographically made very sparse scenes
1775
+ // look optimal even when a human would immediately compact them.
1776
+ const fields = [
1777
+ "spatialTraversals",
1778
+ "spatialOverlaps"
1779
+ ];
1780
+ for (const field of fields) {
1781
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1782
+ if (Math.abs(difference) > 1e-7) return difference;
1783
+ }
1784
+ const scoreDifference = (left?.aestheticScore || 0) - (right?.aestheticScore || 0);
1785
+ if (Math.abs(scoreDifference) > 1e-7) return scoreDifference;
1786
+ // Stable deterministic tie-breakers retain the authored/candidate order only
1787
+ // after two layouts are perceptually indistinguishable.
1788
+ for (const field of ["frontOcclusion", "frontOverlaps", "normalizedEdgeLength", "compactnessPenalty", "footprint"]) {
1789
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1790
+ if (Math.abs(difference) > 1e-7) return difference;
1791
+ }
1792
+ return (left?.score || 0) - (right?.score || 0);
1793
+ }
1794
+
1795
+ export function analyzeAtlasLayout(model, options = {}) {
1796
+ return { ...autoLayoutMetrics(model, options) };
1797
+ }
1798
+
1799
+ function resolveAutomaticLayouts(input, options = {}) {
1800
+ const authored = structuredClone(normalizeRoot(input));
1801
+ const source = structuredClone(authored);
1802
+ const owners = autoLayoutOwners(source);
1803
+ if (!owners.length) return null;
1804
+
1805
+ for (const entry of owners) {
1806
+ const initial = autoLayoutCandidates(entry)[0];
1807
+ entry.owner.layout = initial;
1808
+ }
1809
+ const decisions = new Map();
1810
+ const metricOptions = { camera: options.autoLayoutCamera, source };
1811
+ let metrics = autoLayoutMetrics(compileResolvedAtlasModel(source, { ...options, throwOnFatal: false }), metricOptions);
1812
+ for (let pass = 0; pass < 3; pass++) {
1813
+ let changed = false;
1814
+ // Resolve nested content before its owner. Container dimensions and the
1815
+ // external connection lanes are then stable when the parent chooses its
1816
+ // global arrangement, avoiding a sparse root decision based on provisional
1817
+ // child layouts.
1818
+ for (const entry of [...owners].reverse()) {
1819
+ const previous = entry.owner.layout;
1820
+ let best = { layout: previous, metrics };
1821
+ for (const candidate of autoLayoutCandidates(entry)) {
1822
+ entry.owner.layout = candidate;
1823
+ const candidateModel = compileResolvedAtlasModel(source, { ...options, throwOnFatal: false });
1824
+ const candidateMetrics = autoLayoutMetrics(candidateModel, metricOptions);
1825
+ if (compareAutoLayoutMetrics(candidateMetrics, best.metrics) < 0) best = { layout: candidate, metrics: candidateMetrics };
1826
+ }
1827
+ entry.owner.layout = best.layout;
1828
+ if (JSON.stringify(best.layout) !== JSON.stringify(previous)) changed = true;
1829
+ metrics = best.metrics;
1830
+ decisions.set(entry.id, best.layout);
1831
+ }
1832
+ if (!changed) break;
1833
+ }
1834
+ return { authored, source, owners, decisions, metrics };
1835
+ }
1836
+
1837
+ function compileResolvedAtlasModel(input, options = {}) {
1050
1838
  const diagnostics = [];
1051
1839
  const source = normalizeRoot(input);
1052
1840
  const palette = paletteFrom(source);
@@ -1057,6 +1845,8 @@ export function compileAtlasModel(input, options = {}) {
1057
1845
  const relations = normalizeRelations(source.relations, validIds, diagnostics);
1058
1846
  resolveStyles(flat.entities, flat.sourceById, flat.childrenById, palette, diagnostics);
1059
1847
  const byId = new Map(flat.entities.map(entity => [entity.id, entity]));
1848
+ const graphContext = { relations, byId };
1849
+ graphContext.graphOrderHints = globalGraphOrderHints(source, flat, relations, byId);
1060
1850
  const geometrySizes = { ...DEFAULT_SIZES };
1061
1851
  for (const definition of options.geometryCatalog || []) {
1062
1852
  if (!definition?.id || !Array.isArray(definition.canonicalSize) || definition.canonicalSize.length !== 3) continue;
@@ -1081,15 +1871,11 @@ export function compileAtlasModel(input, options = {}) {
1081
1871
  const spec = sourceEntity.layout || { algorithm: "grid" };
1082
1872
  const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
1083
1873
  if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
1084
- if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK", `Graph layout for '${entityId}' is reserved for a later compiler pass; using grid for now.`, { entityId });
1085
1874
  const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
1086
1875
  const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
1087
- algorithm === "row"
1088
- ? rowLayout
1089
- : algorithm === "volume"
1090
- ? volumeLayout
1091
- : gridLayout,
1092
- declared);
1876
+ layoutFunction(spec, diagnostics, entityId, graphContext),
1877
+ declared,
1878
+ graphContext);
1093
1879
  for (const placement of result.positions) {
1094
1880
  const child = byId.get(placement.entity.id);
1095
1881
  child.p = placement.position;
@@ -1107,13 +1893,10 @@ export function compileAtlasModel(input, options = {}) {
1107
1893
  const rootItems = flat.entities.filter(entity => !entity.parent).map(entity => ({ entity, size: layoutEntity(entity.id) }));
1108
1894
  const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
1109
1895
  const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
1110
- if (rootAlgorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK", "Graph layout for the root is reserved for a later compiler pass; using grid for now.");
1111
1896
  const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
1112
- rootAlgorithm === "row"
1113
- ? rowLayout
1114
- : rootAlgorithm === "volume"
1115
- ? volumeLayout
1116
- : gridLayout);
1897
+ layoutFunction(rootSpec, diagnostics, null, graphContext),
1898
+ null,
1899
+ graphContext);
1117
1900
  for (const placement of rootResult.positions) {
1118
1901
  const entity = byId.get(placement.entity.id);
1119
1902
  entity.p = placement.position;
@@ -1139,6 +1922,39 @@ export function compileAtlasModel(input, options = {}) {
1139
1922
  return output;
1140
1923
  }
1141
1924
 
1925
+ export function compileAtlasModel(input, options = {}) {
1926
+ const automatic = resolveAutomaticLayouts(input, options);
1927
+ if (!automatic) return compileResolvedAtlasModel(input, options);
1928
+ const model = compileResolvedAtlasModel(automatic.source, options);
1929
+ model.layout = automatic.authored.layout || undefined;
1930
+ for (const entry of automatic.owners) {
1931
+ const resolved = automatic.decisions.get(entry.id) || entry.owner.layout;
1932
+ diagnostic(model.diagnostics, "info", "AUTO_LAYOUT_RESOLVED",
1933
+ `Automatic layout resolved '${entry.id ?? "root"}' to ${resolved.algorithm} on ${resolved.direction || resolved.plane || "default axes"}.`, {
1934
+ ownerId: entry.id,
1935
+ resolvedLayout: { ...resolved },
1936
+ score: automatic.metrics.score,
1937
+ crossings: automatic.metrics.crossings,
1938
+ traversals: automatic.metrics.traversals,
1939
+ worstCrossings: automatic.metrics.worstCrossings,
1940
+ worstTraversals: automatic.metrics.worstTraversals,
1941
+ spatialTraversals: automatic.metrics.spatialTraversals,
1942
+ spatialOverlaps: automatic.metrics.spatialOverlaps,
1943
+ frontCrossings: automatic.metrics.frontCrossings,
1944
+ frontTraversals: automatic.metrics.frontTraversals,
1945
+ frontOverlaps: automatic.metrics.frontOverlaps,
1946
+ frontOcclusion: automatic.metrics.frontOcclusion,
1947
+ orientationMismatches: automatic.metrics.orientationMismatches,
1948
+ elongation: automatic.metrics.elongation,
1949
+ normalizedEdgeLength: automatic.metrics.normalizedEdgeLength,
1950
+ compactnessPenalty: automatic.metrics.compactnessPenalty,
1951
+ orthogonalDeviation: automatic.metrics.orthogonalDeviation,
1952
+ volumeRatio: automatic.metrics.volumeRatio
1953
+ });
1954
+ }
1955
+ return model;
1956
+ }
1957
+
1142
1958
  export function compileAtlasModelWithDiagnostics(input, options = {}) {
1143
1959
  const model = compileAtlasModel(input, options);
1144
1960
  return { model, diagnostics: model.diagnostics || [] };
package/src/spec.js CHANGED
@@ -32,6 +32,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
32
32
  text: field("Description shown by the inspector.", null, { type: "string" }),
33
33
  url: field("External documentation opened from the inspector.", null, { type: "url" }),
34
34
  type: field("Structural role. Use container when declaring children; omit it for a leaf item.", ["container"]),
35
+ open: field("Initial expansion state for a container; omitted means closed.", [true, false]),
35
36
  parent: field("Parent entity id.", null, { type: "string", authoring: "absolute" }),
36
37
  children: field("Nested entities.", null, { context: "entity", sequence: true }),
37
38
  geometry: field("Geometry id supplied by Atlas or a registered pack.", null, { valueSource: "geometryCatalog" }),
@@ -70,7 +71,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
70
71
  layout: freeze({
71
72
  label: "Layout",
72
73
  properties: freeze({
73
- algorithm: field("Spatial strategy.", ["row", "grid", "volume", "graph"]),
74
+ algorithm: field("Spatial strategy. Auto evaluates the whole model; graph derives layers and sibling order from relations.", ["auto", "row", "grid", "volume", "graph"]),
74
75
  direction: field("Primary semantic axis.", ["x", "y", "z"]),
75
76
  variant: field("Regular cells or masonry rows staggered by half a cell by default.", ["uniform", "masonry"]),
76
77
  plane: field("Axes used by a two-dimensional grid.", ["xz", "xy", "yz"]),
@@ -140,10 +141,17 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
140
141
  selectionMode: field("Selection ownership.", ["internal", "event"]),
141
142
  toolbar: field("Built-in toolbar visibility.", [true, false]),
142
143
  shellLabels: field("Text-label contrast outline.", [true, false]),
144
+ navigation: field("Camera behavior triggered by selection.", null, { context: "navigation" }),
143
145
  focus: field("Selection-driven visual filtering.", null, { context: "focus" }),
144
146
  camera: field("Initial camera snapshot in model-space coordinates.", null, { context: "camera" })
145
147
  })
146
148
  }),
149
+ navigation: freeze({
150
+ label: "Selection navigation",
151
+ properties: freeze({
152
+ onSelect: field("Camera movement after a click or select() call.", ["preserve", "center", "fit"])
153
+ })
154
+ }),
147
155
  focus: freeze({
148
156
  label: "Visual focus",
149
157
  properties: freeze({
@@ -199,6 +207,7 @@ export function atlasLayoutContextForPath(path = []) {
199
207
  ["palette", "palette"],
200
208
  ["theme", "theme"],
201
209
  ["render", "render"],
210
+ ["navigation", "navigation"],
202
211
  ["focus", "focus"],
203
212
  ["camera", "camera"],
204
213
  ["span", "span"],