@liquidcars/atlas-layout 0.1.9 → 0.1.13

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
 
@@ -134,13 +139,15 @@ relations:
134
139
  from: source
135
140
  to: rules
136
141
  label: feeds
142
+ text: Sends normalized source data to the rules engine.
143
+ url: https://example.com/relations/source-to-rules
137
144
  mode: bidirectional
138
145
  - from: rules
139
146
  to: output
140
147
  mode: broken
141
148
  ```
142
149
 
143
- `mode` es la forma recomendada y el compilador siempre la emite normalizada. Para facilitar la migración se aceptan también `visual`, `render`, `type`, `direction`, `bidirectional: true` y `broken: true`, además de los alias `two-way`, `both` e `interrupted`. La forma abreviada puede incluir el modo como cuarto valor: `[from, to, label, mode]`. Un modo desconocido conserva la relación como `directed` y añade el diagnóstico `UNKNOWN_RELATION_MODE`.
150
+ `label` es el título corto visible junto a la conexión, `text` permite documentar su significado y `url` enlaza documentación externa desde el panel de información. `mode` es la forma recomendada y el compilador siempre la emite normalizada. Para facilitar la migración se aceptan también `visual`, `render`, `type`, `direction`, `bidirectional: true` y `broken: true`, además de los alias `two-way`, `both` e `interrupted`. La forma abreviada puede incluir el modo como cuarto valor: `[from, to, label, mode]`. Un modo desconocido conserva la relación como `directed` y añade el diagnóstico `UNKNOWN_RELATION_MODE`.
144
151
 
145
152
  En un documento Markdown dividido, el bloque de relaciones contiene una lista YAML:
146
153
 
@@ -194,4 +201,67 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
194
201
 
195
202
  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.
196
203
 
197
- `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. 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.
204
+ `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.
205
+
206
+ `auto` delega en el optimizador global la elección de algoritmo, dirección y
207
+ plano para ese propietario de layout. El compilador prueba alternativas de
208
+ `graph`, `row`, `grid` y `volume` mediante una búsqueda determinista y puntúa el modelo
209
+ completo, no cada contenedor de forma aislada. La búsqueda resuelve primero los
210
+ contenidos anidados y después sus propietarios, de modo que la composición global
211
+ se decide con tamaños y afinidades estables. `graph` también aporta variantes
212
+ espaciales internas con varios carriles de profundidad; `auto` puede elegirlas
213
+ sin que el autor tenga que fijar un nuevo algoritmo. Las intersecciones físicas
214
+ 3D y los solapamientos son condiciones estrictas. Entre las soluciones válidas,
215
+ la función perceptiva equilibra los atravesamientos y cruces proyectados con la
216
+ legibilidad frontal, la alineación ortogonal, la longitud media de las relaciones,
217
+ la compacidad volumétrica, la coherencia de orientación y las proporciones
218
+ excesivamente alargadas. Los carriles de profundidad automáticos emplean un paso
219
+ compacto; un layout `graph` explícito conserva el paso completo. Un `direction`, `plane`,
220
+ `gap` o `padding` declarado junto a `auto` queda bloqueado y limita las
221
+ alternativas. Si no se declara `gap`, se utiliza `3`.
222
+
223
+ ```yaml
224
+ layout:
225
+ algorithm: auto
226
+ # direction: z # opcional: bloquea el eje, pero no el algoritmo resuelto
227
+ ```
228
+
229
+ La cámara inicial de `render.camera` es la vista principal de la puntuación. Se
230
+ combina con una vista frontal canónica —que mide cuántos frentes quedan ocultos—
231
+ y varias perspectivas oblicuas para evitar una solución que solo resulte legible
232
+ desde un ángulo. Un host como
233
+ Atlas Studio también puede pasar la vista interactiva con
234
+ `compileAtlasModel(source, { autoLayoutCamera: camera })`. Cada propietario
235
+ automático produce un diagnóstico informativo `AUTO_LAYOUT_RESOLVED` con la
236
+ configuración elegida y las métricas globales, incluidas la oclusión frontal,
237
+ las peores métricas proyectadas y las intersecciones espaciales. `analyzeAtlasLayout(model,
238
+ { camera })` permite puntuar un modelo ya compilado con la misma función
239
+ objetivo.
240
+
241
+ `graph` implementa un layout por capas sensible a las relaciones. Proyecta las
242
+ relaciones de descendientes sobre los hijos inmediatos del contenedor, asigna
243
+ capas según la dirección del flujo y utiliza barridos de baricentro para reducir
244
+ cruces y longitud de conexiones. Los empates conservan el orden de autoría para
245
+ que el resultado sea determinista. Las relaciones con `layout: false` no
246
+ participan. Los ciclos se rompen de forma estable y producen el diagnóstico
247
+ `GRAPH_CYCLE_STABILIZED`. Cuando varios contenedores usan `graph`, una pasada
248
+ jerárquica adicional utiliza las relaciones que cruzan sus límites para alinear
249
+ carriles entre contenedores. De este modo, dos elementos relacionados pueden
250
+ quedar en la misma vertical u horizontal aunque no sean hermanos directos.
251
+
252
+ El `gap` predeterminado de `graph` es `3`; puede reducirse de forma explícita
253
+ para modelos compactos. Las relaciones siguen determinando las capas sobre
254
+ `direction`. Elegir automáticamente entre composiciones alternativas en `x`,
255
+ `y` o `z` pertenece a una fase de optimización asistida, no al compilador
256
+ determinista.
257
+
258
+ ```yaml
259
+ layout:
260
+ algorithm: graph
261
+ direction: y
262
+ plane: xy
263
+ gap: 3
264
+ ```
265
+
266
+ El enrutado avanzado de tubos alrededor de geometrías permanece como una fase
267
+ 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.9",
3
+ "version": "0.1.13",
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, 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,9 +74,42 @@ 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
- a plane of xz (the default), xy or yz. Volume layouts additionally accept layers.
109
+ a plane of xz (the default), xy or yz. Masonry offsets alternate rows by half a cell
110
+ unless `stagger.offset` explicitly overrides that distance. Use plane xy for layouts
111
+ intended to remain legible from the natural frontal camera, or yz for a lateral view.
112
+ Volume layouts additionally accept layers.
77
113
  gap and padding are absolute numbers. justify accepts start, center, end,
78
114
  space-between, space-around or space-evenly. These modes become distinct when a
79
115
  row receives an explicit or constrained size. Distributed modes treat gap as the
@@ -145,7 +181,10 @@ constraints:
145
181
 
146
182
  Relations require from and to entity ids. mode is directed, bidirectional or broken.
147
183
  The aliases visual, render, type and direction are accepted for compatibility.
148
- Optional fields include id, label, fromAnchor, toAnchor, priority and layout.
184
+ Optional fields include id, label, text, url, fromAnchor, toAnchor, priority and layout.
185
+ `label` is the compact caption associated with the connection; `text` is its longer
186
+ description and `url` links to external documentation when the relation is selected
187
+ in the information panel.
149
188
 
150
189
  ## Rendering
151
190
 
package/src/index.js CHANGED
@@ -142,6 +142,18 @@ function normalizeEntityTypes(flat, geometryCatalog, diagnostics) {
142
142
  const type = hasChildren || declared === "container" || declared === "shell" ? "container" : "item";
143
143
  entity.type = type;
144
144
 
145
+ if (Object.hasOwn(source, "open") && typeof source.open !== "boolean") {
146
+ diagnostic(diagnostics, "fatal", "INVALID_CONTAINER_OPEN_STATE",
147
+ "Entity '" + entity.id + "' has a non-boolean open value. Use true or omit the property.",
148
+ { entityId: entity.id, open: source.open });
149
+ } else if (Object.hasOwn(source, "open") && type !== "container") {
150
+ diagnostic(diagnostics, "fatal", "OPEN_STATE_REQUIRES_CONTAINER",
151
+ "Entity '" + entity.id + "' declares open but is not a container.",
152
+ { entityId: entity.id, open: source.open });
153
+ }
154
+ if (type === "container" && source.open === true) entity.open = true;
155
+ else delete entity.open;
156
+
145
157
  if (type === "container" && source.geometry && !isPrimitiveGeometry(source.geometry, geometryCatalog)) {
146
158
  diagnostic(diagnostics, "fatal", "CUSTOM_GEOMETRY_ON_CONTAINER",
147
159
  "Container '" + entity.id + "' cannot use non-primitive geometry '" + source.geometry + "'.",
@@ -242,6 +254,8 @@ function normalizeRelations(input, validIds, diagnostics) {
242
254
  index
243
255
  };
244
256
  if (relation.id != null) normalized.id = String(relation.id);
257
+ if (relation.text != null) normalized.text = String(relation.text);
258
+ if (relation.url != null) normalized.url = String(relation.url);
245
259
  if (relation.fromAnchor != null || relation.from_anchor != null) normalized.fromAnchor = String(relation.fromAnchor ?? relation.from_anchor);
246
260
  if (relation.toAnchor != null || relation.to_anchor != null) normalized.toAnchor = String(relation.toAnchor ?? relation.to_anchor);
247
261
  return [normalized];
@@ -565,20 +579,20 @@ function compactRowBands(placements, constraints, spec, lockedAxes = new Map())
565
579
  }
566
580
  }
567
581
 
568
- function layoutFunction(spec, diagnostics, ownerId) {
582
+ function layoutFunction(spec, diagnostics, ownerId, graphContext = null) {
569
583
  const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
570
584
  if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
571
585
  `Unknown layout algorithm '${spec?.algorithm}', using grid.`, { entityId: ownerId });
572
- if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK",
573
- `Graph layout for '${ownerId || "root"}' is reserved for a later compiler pass; using grid for now.`, { entityId: ownerId });
574
586
  return algorithm === "row"
575
587
  ? rowLayout
576
588
  : algorithm === "volume"
577
589
  ? volumeLayout
578
- : gridLayout;
590
+ : algorithm === "graph"
591
+ ? (items, layoutSpec, layoutDiagnostics) => graphLayout(items, layoutSpec, layoutDiagnostics, graphContext, ownerId)
592
+ : gridLayout;
579
593
  }
580
594
 
581
- function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
595
+ function materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext = null) {
582
596
  const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
583
597
  if (!definitions.length) return { items, groups: new Map() };
584
598
 
@@ -634,7 +648,9 @@ function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
634
648
  definition,
635
649
  diagnostics,
636
650
  definition.id,
637
- layoutFunction(groupSpec, diagnostics, definition.id)
651
+ layoutFunction(groupSpec, diagnostics, definition.id, graphContext),
652
+ null,
653
+ graphContext
638
654
  );
639
655
  const virtual = {
640
656
  entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
@@ -700,8 +716,8 @@ function flattenLayoutGroups(placements) {
700
716
  return flattened;
701
717
  }
702
718
 
703
- function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
704
- const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId);
719
+ function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null, graphContext = null) {
720
+ const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext);
705
721
  const constraints = layoutConstraints(source, spec);
706
722
  applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
707
723
  reflowLayoutGroups(grouped.items, diagnostics);
@@ -890,7 +906,12 @@ function packedGridLayout(items, spec, diagnostics, dimension = 2) {
890
906
  for (const item of placements) {
891
907
  const columnSize = item.span[0] * columnCell + (item.span[0] - 1) * gap;
892
908
  const rowSize = item.span[1] * rowCell + (item.span[1] - 1) * gap;
893
- const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (columnCell + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
909
+ const staggerOffset = spec.stagger?.offset;
910
+ const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1
911
+ ? (staggerOffset == null || staggerOffset === "half-cell"
912
+ ? (columnCell + gap) / 2
913
+ : scalar(staggerOffset, 0))
914
+ : 0;
894
915
  const slotSize = [0, 0, 0];
895
916
  slotSize[columnAxis] = columnSize;
896
917
  slotSize[rowAxis] = rowSize;
@@ -949,6 +970,180 @@ function volumeLayout(items, spec, diagnostics) {
949
970
  return packedGridLayout(items, spec, diagnostics, 3);
950
971
  }
951
972
 
973
+ function graphAxes(spec = {}) {
974
+ const main = { x: 0, y: 1, z: 2 }[spec.direction] ?? 1;
975
+ const plane = GRID_PLANES[spec.plane] || (main === 2 ? GRID_PLANES.xz : GRID_PLANES.xy);
976
+ const cross = plane.find(axis => axis !== main) ?? [0, 1, 2].find(axis => axis !== main);
977
+ const depth = [0, 1, 2].find(axis => axis !== main && axis !== cross);
978
+ return { main, cross, depth };
979
+ }
980
+
981
+ function graphEndpointItem(endpointId, itemIds, directItemById, byId) {
982
+ let cursor = byId?.get(endpointId);
983
+ const seen = new Set();
984
+ while (cursor && !seen.has(cursor.id)) {
985
+ seen.add(cursor.id);
986
+ const direct = directItemById.get(cursor.id);
987
+ if (direct && itemIds.has(direct)) return direct;
988
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
989
+ }
990
+ return null;
991
+ }
992
+
993
+ function graphLayout(items, spec, diagnostics, context = null, ownerId = null) {
994
+ const gap = Math.max(0, scalar(spec.gap, 3));
995
+ const padding = Math.max(0, scalar(spec.padding, 1));
996
+ if (!items.length) return boundsOf([], padding);
997
+
998
+ const ids = items.map(item => item.entity.id);
999
+ const itemIds = new Set(ids);
1000
+ const authoredIndex = new Map(ids.map((id, index) => [id, index]));
1001
+ const directItemById = new Map();
1002
+ for (const item of items) {
1003
+ directItemById.set(item.entity.id, item.entity.id);
1004
+ if (item.entity.__atlasVirtualGroup) {
1005
+ for (const member of item.memberItems || []) directItemById.set(member.entity.id, item.entity.id);
1006
+ }
1007
+ }
1008
+
1009
+ const edgeWeights = new Map();
1010
+ for (const relation of context?.relations || []) {
1011
+ if (relation.layout === false) continue;
1012
+ const from = graphEndpointItem(relation.from, itemIds, directItemById, context.byId);
1013
+ const to = graphEndpointItem(relation.to, itemIds, directItemById, context.byId);
1014
+ if (!from || !to || from === to) continue;
1015
+ const key = `${from}\u0000${to}`;
1016
+ edgeWeights.set(key, (edgeWeights.get(key) || 0) + positiveNumber(relation.priority, 1));
1017
+ }
1018
+ const edges = [...edgeWeights].map(([key, weight]) => {
1019
+ const [from, to] = key.split("\u0000");
1020
+ return { from, to, weight };
1021
+ });
1022
+
1023
+ const predecessors = new Map(ids.map(id => [id, []]));
1024
+ const successors = new Map(ids.map(id => [id, []]));
1025
+ const indegree = new Map(ids.map(id => [id, 0]));
1026
+ for (const edge of edges) {
1027
+ predecessors.get(edge.to).push(edge);
1028
+ successors.get(edge.from).push(edge);
1029
+ indegree.set(edge.to, indegree.get(edge.to) + 1);
1030
+ }
1031
+
1032
+ const layerById = new Map(ids.map(id => [id, 0]));
1033
+ const processed = new Set();
1034
+ let cycleReported = false;
1035
+ while (processed.size < ids.length) {
1036
+ let available = ids.filter(id => !processed.has(id) && indegree.get(id) === 0);
1037
+ if (!available.length) {
1038
+ available = ids.filter(id => !processed.has(id)).slice(0, 1);
1039
+ if (!cycleReported) {
1040
+ cycleReported = true;
1041
+ diagnostic(diagnostics, "warning", "GRAPH_CYCLE_STABILIZED",
1042
+ "Graph layout contains a directed cycle; authored order was used to break it deterministically.");
1043
+ }
1044
+ }
1045
+ available.sort((left, right) => authoredIndex.get(left) - authoredIndex.get(right));
1046
+ const id = available[0];
1047
+ const resolvedPredecessors = predecessors.get(id).filter(edge => processed.has(edge.from));
1048
+ layerById.set(id, resolvedPredecessors.length
1049
+ ? Math.max(...resolvedPredecessors.map(edge => layerById.get(edge.from) + 1))
1050
+ : 0);
1051
+ processed.add(id);
1052
+ for (const edge of successors.get(id)) indegree.set(edge.to, Math.max(0, indegree.get(edge.to) - 1));
1053
+ }
1054
+
1055
+ const layers = [];
1056
+ for (const id of ids) {
1057
+ const layer = layerById.get(id);
1058
+ layers[layer] ||= [];
1059
+ layers[layer].push(id);
1060
+ }
1061
+ const order = new Map();
1062
+ const updateOrder = () => layers.forEach(layer => layer.forEach((id, index) => order.set(id, index)));
1063
+ const ownerHints = context?.graphOrderHints?.get(ownerId ?? null);
1064
+ const reorder = (layer, neighbors) => layer.sort((left, right) => {
1065
+ const score = id => {
1066
+ const related = neighbors.get(id).filter(edge => order.has(edge.from === id ? edge.to : edge.from));
1067
+ const external = ownerHints?.get(id);
1068
+ if (!related.length && !external) return null;
1069
+ const internalWeight = related.reduce((sum, edge) => sum + edge.weight, 0);
1070
+ const internalScore = related.reduce((sum, edge) => {
1071
+ const other = edge.from === id ? edge.to : edge.from;
1072
+ const otherLayer = layers[layerById.get(other)] || [];
1073
+ const normalizedOrder = otherLayer.length > 1
1074
+ ? order.get(other) / (otherLayer.length - 1)
1075
+ : .5;
1076
+ return sum + normalizedOrder * Math.max(0, layer.length - 1) * edge.weight;
1077
+ }, 0);
1078
+ const externalWeight = external?.weight || 0;
1079
+ const externalScore = (external?.rank || 0) * Math.max(0, layer.length - 1);
1080
+ const totalWeight = internalWeight + externalWeight;
1081
+ return totalWeight ? (internalScore + externalScore * externalWeight) / totalWeight : null;
1082
+ };
1083
+ const leftScore = score(left);
1084
+ const rightScore = score(right);
1085
+ if (leftScore == null && rightScore == null) return authoredIndex.get(left) - authoredIndex.get(right);
1086
+ if (leftScore == null) return 1;
1087
+ if (rightScore == null) return -1;
1088
+ return leftScore - rightScore || authoredIndex.get(left) - authoredIndex.get(right);
1089
+ });
1090
+
1091
+ updateOrder();
1092
+ if (ownerHints?.size) {
1093
+ const adjacent = new Map(ids.map(id => [id, [...predecessors.get(id), ...successors.get(id)]]));
1094
+ for (const layer of layers) reorder(layer, adjacent);
1095
+ updateOrder();
1096
+ }
1097
+ for (let pass = 0; pass < 6; pass++) {
1098
+ for (let layer = 1; layer < layers.length; layer++) {
1099
+ reorder(layers[layer], predecessors);
1100
+ updateOrder();
1101
+ }
1102
+ for (let layer = layers.length - 2; layer >= 0; layer--) {
1103
+ reorder(layers[layer], successors);
1104
+ updateOrder();
1105
+ }
1106
+ }
1107
+
1108
+ const { main, cross, depth } = graphAxes(spec);
1109
+ const byItemId = new Map(items.map(item => [item.entity.id, item]));
1110
+ const layerExtents = layers.map(layer => Math.max(...layer.map(id => byItemId.get(id).size[main])));
1111
+ const mainPositions = [];
1112
+ let mainCursor = 0;
1113
+ for (let index = 0; index < layers.length; index++) {
1114
+ if (index) mainCursor += layerExtents[index - 1] / 2 + gap * 1.6 + layerExtents[index] / 2;
1115
+ mainPositions[index] = mainCursor;
1116
+ }
1117
+ const directionSign = main === 0 ? 1 : -1;
1118
+ const spatialLayers = spec.spatial === true
1119
+ ? Math.max(2, Math.min(4, Math.floor(positiveNumber(spec.depthLayers, 3))))
1120
+ : 1;
1121
+ const spatialPhase = Math.floor(scalar(spec.spatialPhase, 0));
1122
+ const depthPitch = (Math.max(...items.map(item => item.size[depth]), 1) + gap)
1123
+ * positiveNumber(spec.depthScale, 1);
1124
+ const placements = [];
1125
+ layers.forEach((layer, layerIndex) => {
1126
+ const totalCross = layer.reduce((sum, id) => sum + byItemId.get(id).size[cross], 0)
1127
+ + Math.max(0, layer.length - 1) * gap;
1128
+ let crossCursor = -totalCross / 2;
1129
+ for (let itemIndex = 0; itemIndex < layer.length; itemIndex++) {
1130
+ const id = layer[itemIndex];
1131
+ const item = byItemId.get(id);
1132
+ const position = [0, 0, 0];
1133
+ crossCursor += item.size[cross] / 2;
1134
+ position[main] = mainPositions[layerIndex] * directionSign;
1135
+ position[cross] = crossCursor;
1136
+ if (spatialLayers > 1) {
1137
+ const lane = (layerIndex + itemIndex + spatialPhase) % spatialLayers;
1138
+ position[depth] = (lane - (spatialLayers - 1) / 2) * depthPitch;
1139
+ }
1140
+ crossCursor += item.size[cross] / 2 + gap;
1141
+ placements.push({ ...item, position, graphLayer: layerIndex });
1142
+ }
1143
+ });
1144
+ return boundsOf(placements, padding);
1145
+ }
1146
+
952
1147
  function resolveTheme(source) {
953
1148
  const theme = source.theme || source.styles || {};
954
1149
  const render = source.render || {};
@@ -1039,7 +1234,602 @@ export function compileAtlasProjection(input, visibleIds, options = {}) {
1039
1234
  return compileAtlasModel(projectedAtlasSource(input, visibleIds), options);
1040
1235
  }
1041
1236
 
1042
- export function compileAtlasModel(input, options = {}) {
1237
+ function globalGraphOrderHints(source, flat, relations, byId) {
1238
+ const depthOf = id => {
1239
+ let depth = 0;
1240
+ let cursor = id ? byId.get(id) : null;
1241
+ const seen = new Set();
1242
+ while (cursor?.parent && !seen.has(cursor.id)) {
1243
+ seen.add(cursor.id);
1244
+ depth += 1;
1245
+ cursor = byId.get(cursor.parent);
1246
+ }
1247
+ return depth;
1248
+ };
1249
+ const owners = [];
1250
+ const rootChildren = flat.entities.filter(entity => !entity.parent).map(entity => entity.id);
1251
+ if (source.layout?.algorithm === "graph" && rootChildren.length) {
1252
+ owners.push({ id: null, depth: -1, children: rootChildren });
1253
+ }
1254
+ for (const entity of flat.entities) {
1255
+ const children = flat.childrenById.get(entity.id) || [];
1256
+ const layout = flat.sourceById.get(entity.id)?.layout;
1257
+ if (layout?.algorithm === "graph" && children.length) {
1258
+ owners.push({ id: entity.id, depth: depthOf(entity.id), children: [...children] });
1259
+ }
1260
+ }
1261
+ if (owners.length < 2) return new Map();
1262
+
1263
+ const immediateChild = (endpointId, owner) => {
1264
+ let cursor = byId.get(endpointId);
1265
+ const seen = new Set();
1266
+ while (cursor && !seen.has(cursor.id)) {
1267
+ seen.add(cursor.id);
1268
+ if (owner.id === null && !cursor.parent) return owner.children.includes(cursor.id) ? cursor.id : null;
1269
+ if (cursor.parent === owner.id) return owner.children.includes(cursor.id) ? cursor.id : null;
1270
+ cursor = cursor.parent ? byId.get(cursor.parent) : null;
1271
+ }
1272
+ return null;
1273
+ };
1274
+ const membership = endpointId => owners
1275
+ .map(owner => ({ owner, child: immediateChild(endpointId, owner) }))
1276
+ .filter(item => item.child)
1277
+ .sort((left, right) => right.owner.depth - left.owner.depth)[0] || null;
1278
+
1279
+ const links = new Map(owners.map(owner => [owner.id, new Map(owner.children.map(id => [id, []]))]));
1280
+ for (const relation of relations) {
1281
+ if (relation.layout === false) continue;
1282
+ const from = membership(relation.from);
1283
+ const to = membership(relation.to);
1284
+ if (!from || !to || from.owner.id === to.owner.id) continue;
1285
+ const weight = positiveNumber(relation.priority, 1);
1286
+ links.get(from.owner.id).get(from.child).push({ ownerId: to.owner.id, childId: to.child, weight });
1287
+ links.get(to.owner.id).get(to.child).push({ ownerId: from.owner.id, childId: from.child, weight });
1288
+ }
1289
+
1290
+ const authored = new Map();
1291
+ const ranks = new Map();
1292
+ for (const owner of owners) {
1293
+ const divisor = Math.max(1, owner.children.length - 1);
1294
+ authored.set(owner.id, new Map(owner.children.map((id, index) => [id, index])));
1295
+ ranks.set(owner.id, new Map(owner.children.map((id, index) => [id, index / divisor])));
1296
+ }
1297
+ const sweep = orderedOwners => {
1298
+ for (const owner of orderedOwners) {
1299
+ const ownerLinks = links.get(owner.id);
1300
+ const previousRanks = ranks.get(owner.id);
1301
+ const scored = owner.children.map(id => {
1302
+ const neighbors = ownerLinks.get(id) || [];
1303
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1304
+ const score = total
1305
+ ? neighbors.reduce((sum, neighbor) => sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total
1306
+ : previousRanks.get(id);
1307
+ return { id, score, linked: total > 0 };
1308
+ });
1309
+ scored.sort((left, right) => left.score - right.score
1310
+ || Number(right.linked) - Number(left.linked)
1311
+ || authored.get(owner.id).get(left.id) - authored.get(owner.id).get(right.id));
1312
+ const divisor = Math.max(1, scored.length - 1);
1313
+ ranks.set(owner.id, new Map(scored.map((item, index) => [item.id, index / divisor])));
1314
+ }
1315
+ };
1316
+ for (let pass = 0; pass < 8; pass += 1) {
1317
+ sweep(owners);
1318
+ sweep([...owners].reverse());
1319
+ }
1320
+
1321
+ const hints = new Map();
1322
+ for (const owner of owners) {
1323
+ const ownerHints = new Map();
1324
+ for (const childId of owner.children) {
1325
+ const neighbors = links.get(owner.id).get(childId) || [];
1326
+ const total = neighbors.reduce((sum, neighbor) => sum + neighbor.weight, 0);
1327
+ if (!total) continue;
1328
+ const rank = neighbors.reduce((sum, neighbor) =>
1329
+ sum + (ranks.get(neighbor.ownerId)?.get(neighbor.childId) ?? .5) * neighbor.weight, 0) / total;
1330
+ ownerHints.set(childId, { rank, weight: total * 2 });
1331
+ }
1332
+ if (ownerHints.size) hints.set(owner.id, ownerHints);
1333
+ }
1334
+ return hints;
1335
+ }
1336
+
1337
+ const AUTO_LAYOUT_DEFAULT_CAMERA = Object.freeze({
1338
+ position: [12, 8, 14],
1339
+ target: [0, 0, 0]
1340
+ });
1341
+
1342
+ function autoLayoutOwners(source) {
1343
+ const owners = [];
1344
+ const add = (owner, id, root = false) => {
1345
+ if (owner?.layout?.algorithm === "auto") owners.push({ owner, id, root, authoredLayout: structuredClone(owner.layout) });
1346
+ for (const group of owner?.layout?.groups || []) add(group, `${id ?? "root"}::group:${group.id || owners.length}`);
1347
+ };
1348
+ add(source, null, true);
1349
+ const visit = items => {
1350
+ for (const entity of Array.isArray(items) ? items : []) {
1351
+ add(entity, entity.id);
1352
+ visit(entity.children);
1353
+ }
1354
+ };
1355
+ visit(source.entities);
1356
+ return owners;
1357
+ }
1358
+
1359
+ function autoGraphPlanes(direction) {
1360
+ if (direction === "x") return ["xy", "xz"];
1361
+ if (direction === "y") return ["xy", "yz"];
1362
+ return ["xz", "yz"];
1363
+ }
1364
+
1365
+ function autoLayoutCandidates(entry) {
1366
+ const authored = entry.authoredLayout || entry.owner.layout || {};
1367
+ const common = { ...authored, gap: authored.gap ?? 3, padding: authored.padding ?? 1 };
1368
+ delete common.algorithm;
1369
+ const directions = authored.direction ? [authored.direction] : entry.root ? ["y", "x", "z"] : ["z", "x", "y"];
1370
+ const planes = authored.plane ? [authored.plane] : ["xy", "xz", "yz"];
1371
+ const candidates = [];
1372
+ for (const direction of directions) {
1373
+ const graphPlanes = authored.plane ? planes : autoGraphPlanes(direction);
1374
+ for (const plane of graphPlanes) {
1375
+ candidates.push({ ...common, algorithm: "graph", direction, plane });
1376
+ if (!authored.plane) {
1377
+ for (let spatialPhase = 0; spatialPhase < 3; spatialPhase++) {
1378
+ candidates.push({
1379
+ ...common,
1380
+ algorithm: "graph",
1381
+ direction,
1382
+ plane,
1383
+ spatial: true,
1384
+ depthLayers: 3,
1385
+ spatialPhase,
1386
+ // Automatic depth lanes are an escape route for real collisions,
1387
+ // not a reason to inflate the whole scene. Explicit graph layouts
1388
+ // retain the historical full pitch unless they opt into a scale.
1389
+ depthScale: .55
1390
+ });
1391
+ }
1392
+ }
1393
+ }
1394
+ }
1395
+ for (const direction of directions) candidates.push({ ...common, algorithm: "row", direction });
1396
+ if (!authored.direction || authored.plane) {
1397
+ for (const plane of planes) candidates.push({ ...common, algorithm: "grid", plane });
1398
+ }
1399
+ // Volume has no privileged axis or plane. Only offer it when the author has
1400
+ // not locked either of those decisions; otherwise choosing it would silently
1401
+ // ignore an explicit constraint.
1402
+ if (!authored.direction && !authored.plane) candidates.push({ ...common, algorithm: "volume" });
1403
+ const keys = new Set();
1404
+ return candidates.filter(candidate => {
1405
+ const key = JSON.stringify([
1406
+ candidate.algorithm,
1407
+ candidate.direction || "",
1408
+ candidate.plane || "",
1409
+ candidate.columns || "",
1410
+ candidate.rows || "",
1411
+ candidate.layers || "",
1412
+ candidate.spatial || false,
1413
+ candidate.depthLayers || "",
1414
+ candidate.spatialPhase || "",
1415
+ candidate.depthScale || "",
1416
+ candidate.gap,
1417
+ candidate.padding
1418
+ ]);
1419
+ if (keys.has(key)) return false;
1420
+ keys.add(key);
1421
+ return true;
1422
+ });
1423
+ }
1424
+
1425
+ function vectorSubtract(a, b) {
1426
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
1427
+ }
1428
+
1429
+ function vectorDot(a, b) {
1430
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1431
+ }
1432
+
1433
+ function vectorCross(a, b) {
1434
+ return [
1435
+ a[1] * b[2] - a[2] * b[1],
1436
+ a[2] * b[0] - a[0] * b[2],
1437
+ a[0] * b[1] - a[1] * b[0]
1438
+ ];
1439
+ }
1440
+
1441
+ function vectorNormalize(value, fallback) {
1442
+ const length = Math.hypot(...value);
1443
+ return length > 1e-9 ? value.map(item => item / length) : [...fallback];
1444
+ }
1445
+
1446
+ function autoProjection(render = {}) {
1447
+ const camera = render.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1448
+ const position = asVector(camera.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1449
+ const target = asVector(camera.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1450
+ const forward = vectorNormalize(vectorSubtract(target, position), [0, 0, -1]);
1451
+ let right = vectorNormalize(vectorCross(forward, [0, 1, 0]), [1, 0, 0]);
1452
+ if (Math.abs(vectorDot(right, right)) < 1e-9) right = [1, 0, 0];
1453
+ const up = vectorNormalize(vectorCross(right, forward), [0, 1, 0]);
1454
+ return point => {
1455
+ const relative = vectorSubtract(point, target);
1456
+ return [vectorDot(relative, right), vectorDot(relative, up), vectorDot(relative, forward)];
1457
+ };
1458
+ }
1459
+
1460
+ function orientation2d(a, b, c) {
1461
+ return (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
1462
+ }
1463
+
1464
+ function strictSegmentIntersection2d(a, b, c, d) {
1465
+ const abC = orientation2d(a, b, c);
1466
+ const abD = orientation2d(a, b, d);
1467
+ const cdA = orientation2d(c, d, a);
1468
+ const cdB = orientation2d(c, d, b);
1469
+ const epsilon = 1e-7;
1470
+ return ((abC > epsilon && abD < -epsilon) || (abC < -epsilon && abD > epsilon))
1471
+ && ((cdA > epsilon && cdB < -epsilon) || (cdA < -epsilon && cdB > epsilon));
1472
+ }
1473
+
1474
+ function segmentIntersectsRect2d(a, b, rect) {
1475
+ const inset = Math.min(rect.maxX - rect.minX, rect.maxY - rect.minY) * .08;
1476
+ const minX = rect.minX + inset;
1477
+ const maxX = rect.maxX - inset;
1478
+ const minY = rect.minY + inset;
1479
+ const maxY = rect.maxY - inset;
1480
+ if (minX >= maxX || minY >= maxY) return false;
1481
+ const inside = point => point[0] > minX && point[0] < maxX && point[1] > minY && point[1] < maxY;
1482
+ if (inside(a) || inside(b)) return true;
1483
+ const corners = [[minX, minY], [maxX, minY], [maxX, maxY], [minX, maxY]];
1484
+ return corners.some((corner, index) => strictSegmentIntersection2d(a, b, corner, corners[(index + 1) % corners.length]));
1485
+ }
1486
+
1487
+ function segmentIntersectsBox3d(a, b, box) {
1488
+ let minimum = 0;
1489
+ let maximum = 1;
1490
+ for (let axis = 0; axis < 3; axis++) {
1491
+ const delta = b[axis] - a[axis];
1492
+ if (Math.abs(delta) < 1e-9) {
1493
+ if (a[axis] <= box.min[axis] || a[axis] >= box.max[axis]) return false;
1494
+ continue;
1495
+ }
1496
+ let near = (box.min[axis] - a[axis]) / delta;
1497
+ let far = (box.max[axis] - a[axis]) / delta;
1498
+ if (near > far) [near, far] = [far, near];
1499
+ minimum = Math.max(minimum, near);
1500
+ maximum = Math.min(maximum, far);
1501
+ if (minimum >= maximum) return false;
1502
+ }
1503
+ return maximum > 1e-7 && minimum < 1 - 1e-7;
1504
+ }
1505
+
1506
+ function boxesOverlap3d(left, right) {
1507
+ return [0, 1, 2].every(axis =>
1508
+ Math.min(left.max[axis], right.max[axis]) > Math.max(left.min[axis], right.min[axis]));
1509
+ }
1510
+
1511
+ function projectedLayoutMetrics(model, world, boxes, camera) {
1512
+ const project = autoProjection({ camera });
1513
+ const points = new Map([...world].map(([id, point]) => [id, project(point)]));
1514
+ const rectangles = new Map();
1515
+ for (const [id, box] of boxes) {
1516
+ const corners = [];
1517
+ 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]]) {
1518
+ corners.push(project([x, y, z]));
1519
+ }
1520
+ rectangles.set(id, {
1521
+ minX: Math.min(...corners.map(point => point[0])),
1522
+ maxX: Math.max(...corners.map(point => point[0])),
1523
+ minY: Math.min(...corners.map(point => point[1])),
1524
+ maxY: Math.max(...corners.map(point => point[1]))
1525
+ });
1526
+ }
1527
+ const segments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1528
+ const from = points.get(relation.from);
1529
+ const to = points.get(relation.to);
1530
+ return from && to ? [{ relation, from, to }] : [];
1531
+ });
1532
+ let crossings = 0;
1533
+ for (let left = 0; left < segments.length; left++) for (let right = left + 1; right < segments.length; right++) {
1534
+ const a = segments[left];
1535
+ const b = segments[right];
1536
+ if ([a.relation.from, a.relation.to].some(id => id === b.relation.from || id === b.relation.to)) continue;
1537
+ if (strictSegmentIntersection2d(a.from, a.to, b.from, b.to)) crossings += 1;
1538
+ }
1539
+ let traversals = 0;
1540
+ for (const segment of segments) {
1541
+ for (const [id, rect] of rectangles) {
1542
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1543
+ if (segmentIntersectsRect2d(segment.from, segment.to, rect)) traversals += 1;
1544
+ }
1545
+ }
1546
+ let overlaps = 0;
1547
+ let occlusion = 0;
1548
+ const rectangleEntries = [...rectangles];
1549
+ for (let left = 0; left < rectangleEntries.length; left++) for (let right = left + 1; right < rectangleEntries.length; right++) {
1550
+ const a = rectangleEntries[left][1];
1551
+ const b = rectangleEntries[right][1];
1552
+ const overlapWidth = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX);
1553
+ const overlapHeight = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY);
1554
+ if (overlapWidth > 0 && overlapHeight > 0) {
1555
+ overlaps += 1;
1556
+ const leftArea = Math.max(1e-9, (a.maxX - a.minX) * (a.maxY - a.minY));
1557
+ const rightArea = Math.max(1e-9, (b.maxX - b.minX) * (b.maxY - b.minY));
1558
+ occlusion += overlapWidth * overlapHeight / Math.min(leftArea, rightArea);
1559
+ }
1560
+ }
1561
+ const projected = [...points.values()];
1562
+ const width = projected.length ? Math.max(...projected.map(point => point[0])) - Math.min(...projected.map(point => point[0])) : 0;
1563
+ const height = projected.length ? Math.max(...projected.map(point => point[1])) - Math.min(...projected.map(point => point[1])) : 0;
1564
+ return { crossings, traversals, overlaps, occlusion, footprint: width * height };
1565
+ }
1566
+
1567
+ const AUTO_LAYOUT_FRONT_CAMERA = Object.freeze({
1568
+ position: [0, 0, 100],
1569
+ target: [0, 0, 0]
1570
+ });
1571
+
1572
+ const AUTO_LAYOUT_CANONICAL_CAMERAS = Object.freeze([
1573
+ Object.freeze({ position: [12, 8, 14], target: [0, 0, 0] }),
1574
+ Object.freeze({ position: [-12, 8, 14], target: [0, 0, 0] }),
1575
+ Object.freeze({ position: [12, 8, -14], target: [0, 0, 0] }),
1576
+ Object.freeze({ position: [-12, 8, -14], target: [0, 0, 0] })
1577
+ ]);
1578
+
1579
+ function cameraDirectionKey(camera) {
1580
+ const position = asVector(camera?.position, AUTO_LAYOUT_DEFAULT_CAMERA.position);
1581
+ const target = asVector(camera?.target, AUTO_LAYOUT_DEFAULT_CAMERA.target);
1582
+ return vectorNormalize(vectorSubtract(target, position), [0, 0, -1]).map(value => value.toFixed(3)).join(":");
1583
+ }
1584
+
1585
+ function autoLayoutCameras(model, options = {}) {
1586
+ const primary = options.camera || model.render?.camera || AUTO_LAYOUT_DEFAULT_CAMERA;
1587
+ const seen = new Set();
1588
+ return [{ camera: primary, weight: 1, primary: true }, ...AUTO_LAYOUT_CANONICAL_CAMERAS.map(camera => ({ camera, weight: .35, primary: false }))]
1589
+ .filter(entry => {
1590
+ const key = cameraDirectionKey(entry.camera);
1591
+ if (seen.has(key)) return false;
1592
+ seen.add(key);
1593
+ return true;
1594
+ });
1595
+ }
1596
+
1597
+ function layoutPlane(layout = {}) {
1598
+ if (["graph", "grid"].includes(layout.algorithm) && ["xy", "xz", "yz"].includes(layout.plane)) return layout.plane;
1599
+ if (layout.algorithm === "row") return { x: "yz", y: "xz", z: "xy" }[layout.direction] || null;
1600
+ return null;
1601
+ }
1602
+
1603
+ function layoutOrientationMismatches(source) {
1604
+ if (!source || typeof source !== "object") return 0;
1605
+ let mismatches = 0;
1606
+ const visit = owner => {
1607
+ const expected = layoutPlane(owner.layout);
1608
+ for (const child of Array.isArray(owner.entities) ? owner.entities : Array.isArray(owner.children) ? owner.children : []) {
1609
+ if (isContainerType(child?.type) || Array.isArray(child?.children)) {
1610
+ const actual = layoutPlane(child.layout);
1611
+ if (expected && actual && expected !== actual) mismatches += 1;
1612
+ }
1613
+ visit(child);
1614
+ }
1615
+ };
1616
+ visit(source);
1617
+ return mismatches;
1618
+ }
1619
+
1620
+ function elongationPenalty(extents) {
1621
+ const ordered = extents.map(value => Math.max(0, value)).sort((left, right) => right - left);
1622
+ const ratio = ordered[0] / Math.max(ordered[1], 1e-6);
1623
+ return { ratio, penalty: Math.max(0, ratio - 3) ** 2 };
1624
+ }
1625
+
1626
+ function median(values, fallback = 1) {
1627
+ if (!values.length) return fallback;
1628
+ const ordered = [...values].sort((left, right) => left - right);
1629
+ const middle = Math.floor(ordered.length / 2);
1630
+ return ordered.length % 2
1631
+ ? ordered[middle]
1632
+ : (ordered[middle - 1] + ordered[middle]) / 2;
1633
+ }
1634
+
1635
+ function autoLayoutMetrics(model, options = {}) {
1636
+ const byId = new Map((model.entities || []).map(entity => [entity.id, entity]));
1637
+ const world = new Map();
1638
+ const resolveWorld = id => {
1639
+ if (world.has(id)) return world.get(id);
1640
+ const entity = byId.get(id);
1641
+ if (!entity) return [0, 0, 0];
1642
+ const position = asVector(entity.p);
1643
+ const resolved = entity.parent ? add(resolveWorld(entity.parent), position) : position;
1644
+ world.set(id, resolved);
1645
+ return resolved;
1646
+ };
1647
+ for (const id of byId.keys()) resolveWorld(id);
1648
+ const boxes = new Map();
1649
+ for (const entity of model.entities || []) {
1650
+ if (isContainerType(entity.type)) continue;
1651
+ const center = world.get(entity.id);
1652
+ const size = asVector(entity.s, [1, 1, 1]);
1653
+ boxes.set(entity.id, {
1654
+ min: center.map((value, axis) => value - size[axis] * .42),
1655
+ max: center.map((value, axis) => value + size[axis] * .42)
1656
+ });
1657
+ }
1658
+ const spatialSegments = (model.relations || []).filter(relation => relation.layout !== false).flatMap(relation => {
1659
+ const from = world.get(relation.from);
1660
+ const to = world.get(relation.to);
1661
+ return from && to ? [{ relation, from, to }] : [];
1662
+ });
1663
+ let spatialTraversals = 0;
1664
+ for (const segment of spatialSegments) {
1665
+ for (const [id, box] of boxes) {
1666
+ if (id === segment.relation.from || id === segment.relation.to) continue;
1667
+ if (segmentIntersectsBox3d(segment.from, segment.to, box)) spatialTraversals += 1;
1668
+ }
1669
+ }
1670
+ let spatialOverlaps = 0;
1671
+ const boxEntries = [...boxes];
1672
+ for (let left = 0; left < boxEntries.length; left++) for (let right = left + 1; right < boxEntries.length; right++) {
1673
+ if (boxesOverlap3d(boxEntries[left][1], boxEntries[right][1])) spatialOverlaps += 1;
1674
+ }
1675
+ const views = autoLayoutCameras(model, options).map(entry => ({
1676
+ ...projectedLayoutMetrics(model, world, boxes, entry.camera),
1677
+ weight: entry.weight,
1678
+ primary: entry.primary
1679
+ }));
1680
+ const front = projectedLayoutMetrics(model, world, boxes, AUTO_LAYOUT_FRONT_CAMERA);
1681
+ const primary = views.find(view => view.primary) || views[0] || { crossings: 0, traversals: 0, overlaps: 0, footprint: 0 };
1682
+ const robustCrossings = views.reduce((sum, view) => sum + view.crossings * view.weight, 0);
1683
+ const robustTraversals = views.reduce((sum, view) => sum + view.traversals * view.weight, 0);
1684
+ const robustOverlaps = views.reduce((sum, view) => sum + view.overlaps * view.weight, 0);
1685
+ const footprint = views.reduce((sum, view) => sum + view.footprint * view.weight, 0);
1686
+ const edgeLength = spatialSegments.reduce((sum, segment) => sum + Math.hypot(
1687
+ segment.to[0] - segment.from[0],
1688
+ segment.to[1] - segment.from[1],
1689
+ segment.to[2] - segment.from[2]
1690
+ ), 0);
1691
+ const bounds = [...boxes.values()];
1692
+ const extents = bounds.length ? [0, 1, 2].map(axis =>
1693
+ Math.max(...bounds.map(box => box.max[axis])) - Math.min(...bounds.map(box => box.min[axis]))) : [0, 0, 0];
1694
+ const elongation = elongationPenalty(extents);
1695
+ const orientationMismatches = layoutOrientationMismatches(options.source);
1696
+ const characteristicSize = median([...boxes.values()].map(box => Math.hypot(
1697
+ box.max[0] - box.min[0],
1698
+ box.max[1] - box.min[1],
1699
+ box.max[2] - box.min[2]
1700
+ )));
1701
+ const relationCount = Math.max(1, spatialSegments.length);
1702
+ const normalizedEdgeLength = edgeLength / relationCount / Math.max(characteristicSize, 1e-6);
1703
+ const sceneVolume = extents.reduce((product, extent) => product * Math.max(extent, 1e-6), 1);
1704
+ const itemVolume = [...boxes.values()].reduce((sum, box) => sum
1705
+ + (box.max[0] - box.min[0])
1706
+ * (box.max[1] - box.min[1])
1707
+ * (box.max[2] - box.min[2]), 0);
1708
+ const volumeRatio = sceneVolume / Math.max(itemVolume, 1e-6);
1709
+ const compactnessPenalty = Math.cbrt(Math.max(1, volumeRatio));
1710
+ const frontProject = autoProjection({ camera: AUTO_LAYOUT_FRONT_CAMERA });
1711
+ const orthogonalDeviation = spatialSegments.reduce((sum, segment) => {
1712
+ const from = frontProject(segment.from);
1713
+ const to = frontProject(segment.to);
1714
+ return sum + Math.min(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1]))
1715
+ / Math.max(characteristicSize, 1e-6);
1716
+ }, 0) / relationCount;
1717
+ // Formal 3D validity remains effectively absolute. Once candidates are
1718
+ // physically valid, projected defects compete with the qualities people use
1719
+ // to judge a diagram: short relations, compact grouping and orthogonal flow.
1720
+ // This prevents the optimizer from "winning" by moving valid groups ever
1721
+ // farther apart merely to remove a small projected crossing.
1722
+ const aestheticScore = primary.traversals * 120
1723
+ + front.traversals * 100
1724
+ + robustTraversals * 25
1725
+ + front.overlaps * 250
1726
+ + front.occlusion * 600
1727
+ + primary.crossings * 6
1728
+ + front.crossings * 5
1729
+ + robustCrossings * 2
1730
+ + robustOverlaps * 40
1731
+ + orientationMismatches * 50
1732
+ + elongation.penalty * 30
1733
+ + normalizedEdgeLength * 80
1734
+ + compactnessPenalty * 100
1735
+ + orthogonalDeviation * 35;
1736
+ return {
1737
+ crossings: primary.crossings,
1738
+ traversals: primary.traversals,
1739
+ overlaps: primary.overlaps,
1740
+ worstCrossings: Math.max(0, ...views.map(view => view.crossings)),
1741
+ worstTraversals: Math.max(0, ...views.map(view => view.traversals)),
1742
+ frontCrossings: front.crossings,
1743
+ frontTraversals: front.traversals,
1744
+ frontOverlaps: front.overlaps,
1745
+ frontOcclusion: front.occlusion,
1746
+ spatialTraversals,
1747
+ spatialOverlaps,
1748
+ orientationMismatches,
1749
+ elongation: elongation.ratio,
1750
+ elongationPenalty: elongation.penalty,
1751
+ edgeLength,
1752
+ normalizedEdgeLength,
1753
+ sceneVolume,
1754
+ volumeRatio,
1755
+ compactnessPenalty,
1756
+ orthogonalDeviation,
1757
+ footprint,
1758
+ aestheticScore,
1759
+ views: views.map(({ weight, primary: isPrimary, ...metrics }) => ({ ...metrics, weight, primary: isPrimary })),
1760
+ score: spatialTraversals * 1e12
1761
+ + spatialOverlaps * 1e10
1762
+ + aestheticScore
1763
+ };
1764
+ }
1765
+
1766
+ function compareAutoLayoutMetrics(left, right) {
1767
+ // A line or an item may never pass through another item in real 3D space.
1768
+ // Everything after those two feasibility checks is a perceptual trade-off;
1769
+ // comparing each projected metric lexicographically made very sparse scenes
1770
+ // look optimal even when a human would immediately compact them.
1771
+ const fields = [
1772
+ "spatialTraversals",
1773
+ "spatialOverlaps"
1774
+ ];
1775
+ for (const field of fields) {
1776
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1777
+ if (Math.abs(difference) > 1e-7) return difference;
1778
+ }
1779
+ const scoreDifference = (left?.aestheticScore || 0) - (right?.aestheticScore || 0);
1780
+ if (Math.abs(scoreDifference) > 1e-7) return scoreDifference;
1781
+ // Stable deterministic tie-breakers retain the authored/candidate order only
1782
+ // after two layouts are perceptually indistinguishable.
1783
+ for (const field of ["frontOcclusion", "frontOverlaps", "normalizedEdgeLength", "compactnessPenalty", "footprint"]) {
1784
+ const difference = (left?.[field] || 0) - (right?.[field] || 0);
1785
+ if (Math.abs(difference) > 1e-7) return difference;
1786
+ }
1787
+ return (left?.score || 0) - (right?.score || 0);
1788
+ }
1789
+
1790
+ export function analyzeAtlasLayout(model, options = {}) {
1791
+ return { ...autoLayoutMetrics(model, options) };
1792
+ }
1793
+
1794
+ function resolveAutomaticLayouts(input, options = {}) {
1795
+ const authored = structuredClone(normalizeRoot(input));
1796
+ const source = structuredClone(authored);
1797
+ const owners = autoLayoutOwners(source);
1798
+ if (!owners.length) return null;
1799
+
1800
+ for (const entry of owners) {
1801
+ const initial = autoLayoutCandidates(entry)[0];
1802
+ entry.owner.layout = initial;
1803
+ }
1804
+ const decisions = new Map();
1805
+ const metricOptions = { camera: options.autoLayoutCamera, source };
1806
+ let metrics = autoLayoutMetrics(compileResolvedAtlasModel(source, { ...options, throwOnFatal: false }), metricOptions);
1807
+ for (let pass = 0; pass < 3; pass++) {
1808
+ let changed = false;
1809
+ // Resolve nested content before its owner. Container dimensions and the
1810
+ // external connection lanes are then stable when the parent chooses its
1811
+ // global arrangement, avoiding a sparse root decision based on provisional
1812
+ // child layouts.
1813
+ for (const entry of [...owners].reverse()) {
1814
+ const previous = entry.owner.layout;
1815
+ let best = { layout: previous, metrics };
1816
+ for (const candidate of autoLayoutCandidates(entry)) {
1817
+ entry.owner.layout = candidate;
1818
+ const candidateModel = compileResolvedAtlasModel(source, { ...options, throwOnFatal: false });
1819
+ const candidateMetrics = autoLayoutMetrics(candidateModel, metricOptions);
1820
+ if (compareAutoLayoutMetrics(candidateMetrics, best.metrics) < 0) best = { layout: candidate, metrics: candidateMetrics };
1821
+ }
1822
+ entry.owner.layout = best.layout;
1823
+ if (JSON.stringify(best.layout) !== JSON.stringify(previous)) changed = true;
1824
+ metrics = best.metrics;
1825
+ decisions.set(entry.id, best.layout);
1826
+ }
1827
+ if (!changed) break;
1828
+ }
1829
+ return { authored, source, owners, decisions, metrics };
1830
+ }
1831
+
1832
+ function compileResolvedAtlasModel(input, options = {}) {
1043
1833
  const diagnostics = [];
1044
1834
  const source = normalizeRoot(input);
1045
1835
  const palette = paletteFrom(source);
@@ -1050,6 +1840,8 @@ export function compileAtlasModel(input, options = {}) {
1050
1840
  const relations = normalizeRelations(source.relations, validIds, diagnostics);
1051
1841
  resolveStyles(flat.entities, flat.sourceById, flat.childrenById, palette, diagnostics);
1052
1842
  const byId = new Map(flat.entities.map(entity => [entity.id, entity]));
1843
+ const graphContext = { relations, byId };
1844
+ graphContext.graphOrderHints = globalGraphOrderHints(source, flat, relations, byId);
1053
1845
  const geometrySizes = { ...DEFAULT_SIZES };
1054
1846
  for (const definition of options.geometryCatalog || []) {
1055
1847
  if (!definition?.id || !Array.isArray(definition.canonicalSize) || definition.canonicalSize.length !== 3) continue;
@@ -1074,15 +1866,11 @@ export function compileAtlasModel(input, options = {}) {
1074
1866
  const spec = sourceEntity.layout || { algorithm: "grid" };
1075
1867
  const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
1076
1868
  if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
1077
- 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 });
1078
1869
  const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
1079
1870
  const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
1080
- algorithm === "row"
1081
- ? rowLayout
1082
- : algorithm === "volume"
1083
- ? volumeLayout
1084
- : gridLayout,
1085
- declared);
1871
+ layoutFunction(spec, diagnostics, entityId, graphContext),
1872
+ declared,
1873
+ graphContext);
1086
1874
  for (const placement of result.positions) {
1087
1875
  const child = byId.get(placement.entity.id);
1088
1876
  child.p = placement.position;
@@ -1100,13 +1888,10 @@ export function compileAtlasModel(input, options = {}) {
1100
1888
  const rootItems = flat.entities.filter(entity => !entity.parent).map(entity => ({ entity, size: layoutEntity(entity.id) }));
1101
1889
  const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
1102
1890
  const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
1103
- 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.");
1104
1891
  const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
1105
- rootAlgorithm === "row"
1106
- ? rowLayout
1107
- : rootAlgorithm === "volume"
1108
- ? volumeLayout
1109
- : gridLayout);
1892
+ layoutFunction(rootSpec, diagnostics, null, graphContext),
1893
+ null,
1894
+ graphContext);
1110
1895
  for (const placement of rootResult.positions) {
1111
1896
  const entity = byId.get(placement.entity.id);
1112
1897
  entity.p = placement.position;
@@ -1132,6 +1917,39 @@ export function compileAtlasModel(input, options = {}) {
1132
1917
  return output;
1133
1918
  }
1134
1919
 
1920
+ export function compileAtlasModel(input, options = {}) {
1921
+ const automatic = resolveAutomaticLayouts(input, options);
1922
+ if (!automatic) return compileResolvedAtlasModel(input, options);
1923
+ const model = compileResolvedAtlasModel(automatic.source, options);
1924
+ model.layout = automatic.authored.layout || undefined;
1925
+ for (const entry of automatic.owners) {
1926
+ const resolved = automatic.decisions.get(entry.id) || entry.owner.layout;
1927
+ diagnostic(model.diagnostics, "info", "AUTO_LAYOUT_RESOLVED",
1928
+ `Automatic layout resolved '${entry.id ?? "root"}' to ${resolved.algorithm} on ${resolved.direction || resolved.plane || "default axes"}.`, {
1929
+ ownerId: entry.id,
1930
+ resolvedLayout: { ...resolved },
1931
+ score: automatic.metrics.score,
1932
+ crossings: automatic.metrics.crossings,
1933
+ traversals: automatic.metrics.traversals,
1934
+ worstCrossings: automatic.metrics.worstCrossings,
1935
+ worstTraversals: automatic.metrics.worstTraversals,
1936
+ spatialTraversals: automatic.metrics.spatialTraversals,
1937
+ spatialOverlaps: automatic.metrics.spatialOverlaps,
1938
+ frontCrossings: automatic.metrics.frontCrossings,
1939
+ frontTraversals: automatic.metrics.frontTraversals,
1940
+ frontOverlaps: automatic.metrics.frontOverlaps,
1941
+ frontOcclusion: automatic.metrics.frontOcclusion,
1942
+ orientationMismatches: automatic.metrics.orientationMismatches,
1943
+ elongation: automatic.metrics.elongation,
1944
+ normalizedEdgeLength: automatic.metrics.normalizedEdgeLength,
1945
+ compactnessPenalty: automatic.metrics.compactnessPenalty,
1946
+ orthogonalDeviation: automatic.metrics.orthogonalDeviation,
1947
+ volumeRatio: automatic.metrics.volumeRatio
1948
+ });
1949
+ }
1950
+ return model;
1951
+ }
1952
+
1135
1953
  export function compileAtlasModelWithDiagnostics(input, options = {}) {
1136
1954
  const model = compileAtlasModel(input, options);
1137
1955
  return { model, diagnostics: model.diagnostics || [] };
package/src/spec.js CHANGED
@@ -30,7 +30,9 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
30
30
  name: field("Visible title.", null, { type: "string" }),
31
31
  sub: field("Visible subtitle.", null, { type: "string" }),
32
32
  text: field("Description shown by the inspector.", null, { type: "string" }),
33
+ url: field("External documentation opened from the inspector.", null, { type: "url" }),
33
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]),
34
36
  parent: field("Parent entity id.", null, { type: "string", authoring: "absolute" }),
35
37
  children: field("Nested entities.", null, { context: "entity", sequence: true }),
36
38
  geometry: field("Geometry id supplied by Atlas or a registered pack.", null, { valueSource: "geometryCatalog" }),
@@ -56,6 +58,8 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
56
58
  from: field("Source entity id.", null, { type: "string", required: true }),
57
59
  to: field("Target entity id.", null, { type: "string", required: true }),
58
60
  label: field("Text shown on the relation.", null, { type: "string" }),
61
+ text: field("Description shown by the inspector.", null, { type: "string" }),
62
+ url: field("External documentation opened from the inspector.", null, { type: "url" }),
59
63
  mode: field("Canonical visual relation mode.", ["directed", "bidirectional", "broken"]),
60
64
  visual: field("Historical alias for relation mode.", ["directed", "bidirectional", "broken"]),
61
65
  fromAnchor: field("Named source anchor.", null, { type: "string" }),
@@ -67,9 +71,9 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
67
71
  layout: freeze({
68
72
  label: "Layout",
69
73
  properties: freeze({
70
- 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"]),
71
75
  direction: field("Primary semantic axis.", ["x", "y", "z"]),
72
- variant: field("Regular or staggered grid cells.", ["uniform", "masonry"]),
76
+ variant: field("Regular cells or masonry rows staggered by half a cell by default.", ["uniform", "masonry"]),
73
77
  plane: field("Axes used by a two-dimensional grid.", ["xz", "xy", "yz"]),
74
78
  packing: field("Preserve initial slots or compact free space after constraints.", ["preserve", "compact"]),
75
79
  groups: field("Invisible sibling groups used only during layout.", null, { context: "layoutGroup", sequence: true }),
@@ -79,7 +83,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
79
83
  gap: field("Spacing between children.", null, { type: "number" }),
80
84
  padding: field("Spacing to the container boundary.", null, { type: "number" }),
81
85
  justify: field("Row alignment or distribution within an assigned size.", ["start", "center", "end", "space-between", "space-around", "space-evenly"]),
82
- stagger: field("Optional masonry offset.", null, { context: "stagger" }),
86
+ stagger: field("Optional override for the masonry row offset.", null, { context: "stagger" }),
83
87
  constraints: field("Equal-size and edge-alignment rules for this layout's siblings.", null, { context: "constraint", sequence: true })
84
88
  })
85
89
  }),
@@ -137,10 +141,17 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
137
141
  selectionMode: field("Selection ownership.", ["internal", "event"]),
138
142
  toolbar: field("Built-in toolbar visibility.", [true, false]),
139
143
  shellLabels: field("Text-label contrast outline.", [true, false]),
144
+ navigation: field("Camera behavior triggered by selection.", null, { context: "navigation" }),
140
145
  focus: field("Selection-driven visual filtering.", null, { context: "focus" }),
141
146
  camera: field("Initial camera snapshot in model-space coordinates.", null, { context: "camera" })
142
147
  })
143
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
+ }),
144
155
  focus: freeze({
145
156
  label: "Visual focus",
146
157
  properties: freeze({
@@ -169,7 +180,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
169
180
  stagger: freeze({
170
181
  label: "Stagger",
171
182
  properties: freeze({
172
- offset: field("Masonry row offset.", ["half-cell"], { type: "string" })
183
+ offset: field("Masonry row offset; defaults to half-cell.", ["half-cell"], { type: "string" })
173
184
  })
174
185
  }),
175
186
  label: freeze({ label: "Label appearance", properties: freeze({ color: field("Label colour.", null, { valueSource: "palette" }), opacity: field("Label opacity.", null, { type: "number" }) }) }),
@@ -196,6 +207,7 @@ export function atlasLayoutContextForPath(path = []) {
196
207
  ["palette", "palette"],
197
208
  ["theme", "theme"],
198
209
  ["render", "render"],
210
+ ["navigation", "navigation"],
199
211
  ["focus", "focus"],
200
212
  ["camera", "camera"],
201
213
  ["span", "span"],