@liquidcars/atlas-layout 0.1.10 → 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 +69 -1
- package/package.json +1 -1
- package/spec/ATLAS-LAYOUT-SPEC-v1.md +35 -2
- package/src/index.js +833 -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
|
|
|
@@ -196,4 +201,67 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
|
|
|
196
201
|
|
|
197
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.
|
|
198
203
|
|
|
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.
|
|
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
|
@@ -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
|
@@ -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 + "'.",
|
|
@@ -567,20 +579,20 @@ function compactRowBands(placements, constraints, spec, lockedAxes = new Map())
|
|
|
567
579
|
}
|
|
568
580
|
}
|
|
569
581
|
|
|
570
|
-
function layoutFunction(spec, diagnostics, ownerId) {
|
|
582
|
+
function layoutFunction(spec, diagnostics, ownerId, graphContext = null) {
|
|
571
583
|
const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
|
|
572
584
|
if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
|
|
573
585
|
`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
586
|
return algorithm === "row"
|
|
577
587
|
? rowLayout
|
|
578
588
|
: algorithm === "volume"
|
|
579
589
|
? volumeLayout
|
|
580
|
-
:
|
|
590
|
+
: algorithm === "graph"
|
|
591
|
+
? (items, layoutSpec, layoutDiagnostics) => graphLayout(items, layoutSpec, layoutDiagnostics, graphContext, ownerId)
|
|
592
|
+
: gridLayout;
|
|
581
593
|
}
|
|
582
594
|
|
|
583
|
-
function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
|
|
595
|
+
function materializeLayoutGroups(items, spec, diagnostics, ownerId, graphContext = null) {
|
|
584
596
|
const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
|
|
585
597
|
if (!definitions.length) return { items, groups: new Map() };
|
|
586
598
|
|
|
@@ -636,7 +648,9 @@ function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
|
|
|
636
648
|
definition,
|
|
637
649
|
diagnostics,
|
|
638
650
|
definition.id,
|
|
639
|
-
layoutFunction(groupSpec, diagnostics, definition.id)
|
|
651
|
+
layoutFunction(groupSpec, diagnostics, definition.id, graphContext),
|
|
652
|
+
null,
|
|
653
|
+
graphContext
|
|
640
654
|
);
|
|
641
655
|
const virtual = {
|
|
642
656
|
entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
|
|
@@ -702,8 +716,8 @@ function flattenLayoutGroups(placements) {
|
|
|
702
716
|
return flattened;
|
|
703
717
|
}
|
|
704
718
|
|
|
705
|
-
function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
|
|
706
|
-
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);
|
|
707
721
|
const constraints = layoutConstraints(source, spec);
|
|
708
722
|
applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
|
|
709
723
|
reflowLayoutGroups(grouped.items, diagnostics);
|
|
@@ -956,6 +970,180 @@ function volumeLayout(items, spec, diagnostics) {
|
|
|
956
970
|
return packedGridLayout(items, spec, diagnostics, 3);
|
|
957
971
|
}
|
|
958
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
|
+
|
|
959
1147
|
function resolveTheme(source) {
|
|
960
1148
|
const theme = source.theme || source.styles || {};
|
|
961
1149
|
const render = source.render || {};
|
|
@@ -1046,7 +1234,602 @@ export function compileAtlasProjection(input, visibleIds, options = {}) {
|
|
|
1046
1234
|
return compileAtlasModel(projectedAtlasSource(input, visibleIds), options);
|
|
1047
1235
|
}
|
|
1048
1236
|
|
|
1049
|
-
|
|
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 = {}) {
|
|
1050
1833
|
const diagnostics = [];
|
|
1051
1834
|
const source = normalizeRoot(input);
|
|
1052
1835
|
const palette = paletteFrom(source);
|
|
@@ -1057,6 +1840,8 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
1057
1840
|
const relations = normalizeRelations(source.relations, validIds, diagnostics);
|
|
1058
1841
|
resolveStyles(flat.entities, flat.sourceById, flat.childrenById, palette, diagnostics);
|
|
1059
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);
|
|
1060
1845
|
const geometrySizes = { ...DEFAULT_SIZES };
|
|
1061
1846
|
for (const definition of options.geometryCatalog || []) {
|
|
1062
1847
|
if (!definition?.id || !Array.isArray(definition.canonicalSize) || definition.canonicalSize.length !== 3) continue;
|
|
@@ -1081,15 +1866,11 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
1081
1866
|
const spec = sourceEntity.layout || { algorithm: "grid" };
|
|
1082
1867
|
const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
|
|
1083
1868
|
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
1869
|
const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
|
|
1086
1870
|
const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
? volumeLayout
|
|
1091
|
-
: gridLayout,
|
|
1092
|
-
declared);
|
|
1871
|
+
layoutFunction(spec, diagnostics, entityId, graphContext),
|
|
1872
|
+
declared,
|
|
1873
|
+
graphContext);
|
|
1093
1874
|
for (const placement of result.positions) {
|
|
1094
1875
|
const child = byId.get(placement.entity.id);
|
|
1095
1876
|
child.p = placement.position;
|
|
@@ -1107,13 +1888,10 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
1107
1888
|
const rootItems = flat.entities.filter(entity => !entity.parent).map(entity => ({ entity, size: layoutEntity(entity.id) }));
|
|
1108
1889
|
const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
|
|
1109
1890
|
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
1891
|
const rootResult = constrainedLayout(rootItems, rootSpec, source, diagnostics, null,
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
? volumeLayout
|
|
1116
|
-
: gridLayout);
|
|
1892
|
+
layoutFunction(rootSpec, diagnostics, null, graphContext),
|
|
1893
|
+
null,
|
|
1894
|
+
graphContext);
|
|
1117
1895
|
for (const placement of rootResult.positions) {
|
|
1118
1896
|
const entity = byId.get(placement.entity.id);
|
|
1119
1897
|
entity.p = placement.position;
|
|
@@ -1139,6 +1917,39 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
1139
1917
|
return output;
|
|
1140
1918
|
}
|
|
1141
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
|
+
|
|
1142
1953
|
export function compileAtlasModelWithDiagnostics(input, options = {}) {
|
|
1143
1954
|
const model = compileAtlasModel(input, options);
|
|
1144
1955
|
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"],
|