@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 +73 -1
- package/package.json +1 -1
- package/spec/ATLAS-LAYOUT-SPEC-v1.md +35 -2
- package/src/index.js +838 -22
- package/src/spec.js +10 -1
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.
|
|
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
|
@@ -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,
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
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"],
|