@liquidcars/atlas-layout 0.1.6 → 0.1.8
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 +35 -1
- package/package.json +9 -3
- package/spec/ATLAS-LAYOUT-SPEC-v1.md +40 -4
- package/src/index.js +329 -24
- package/src/spec.js +36 -2
- package/liquidcars-atlas-layout-0.1.3.tgz +0 -0
- package/packages/atlas-layout/package-lock.json +0 -6
- package/test/index.test.mjs +0 -478
package/README.md
CHANGED
|
@@ -24,12 +24,46 @@ Cada entrada del catálogo puede declarar `id`, `aliases` y `canonicalSize`.
|
|
|
24
24
|
Una geometría externa desconocida conserva el tamaño de fallback de una caja,
|
|
25
25
|
de modo que la compilación sigue siendo recuperable si falta un pack.
|
|
26
26
|
|
|
27
|
+
Los grids bidimensionales aceptan `plane: "xz" | "xy" | "yz"`. El plano
|
|
28
|
+
predeterminado sigue siendo `xz`; `xy` resulta útil para galerías concebidas
|
|
29
|
+
para una vista frontal.
|
|
30
|
+
|
|
31
|
+
Los layouts `row` aceptan `packing: "preserve" | "compact"`. El modo
|
|
32
|
+
`compact` recompone las bandas creadas por constraints de alineación sobre el
|
|
33
|
+
eje principal y elimina los huecos que dejan las posiciones iniciales.
|
|
34
|
+
|
|
35
|
+
`layout.groups` permite declarar composiciones invisibles de hermanos inmediatos.
|
|
36
|
+
Cada grupo tiene `id`, `members` y un `layout` interno. Las constraints del
|
|
37
|
+
nivel padre pueden referenciar el id del grupo; tras calcular posiciones, el
|
|
38
|
+
compilador elimina el grupo y emite únicamente las entidades reales.
|
|
39
|
+
|
|
40
|
+
Un layout `row` puede distribuir el espacio asignado por un tamaño explícito
|
|
41
|
+
o una constraint de igualdad mediante `justify: "start" | "center" | "end" |
|
|
42
|
+
"space-between" | "space-around" | "space-evenly"`. `padding` define los límites
|
|
43
|
+
interiores y `gap` actúa como separación mínima. Los grupos virtuales se
|
|
44
|
+
recalculan después de recibir su tamaño definitivo.
|
|
45
|
+
|
|
27
46
|
Las entidades con `children` deben declarar `type: container`. Las hojas pueden
|
|
28
47
|
omitir `type` y se normalizan como items. `type: shell` se acepta únicamente
|
|
29
48
|
como alias de compatibilidad. Un contenedor puede no tener geometría o usar una
|
|
30
49
|
primitiva `geo.*`; las geometrías personalizadas de los packs, como `infra.cloud`,
|
|
31
50
|
sólo se permiten en entidades hoja.
|
|
32
51
|
|
|
52
|
+
La presentación puede conservar una vista inicial sin introducir datos de
|
|
53
|
+
cámara en el algoritmo de layout:
|
|
54
|
+
|
|
55
|
+
```yaml
|
|
56
|
+
render:
|
|
57
|
+
camera:
|
|
58
|
+
position: [12.4, 7.2, 18.6]
|
|
59
|
+
target: [0, 1.5, 0]
|
|
60
|
+
zoom: 1.35
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Las coordenadas son coordenadas del modelo. `zoom` es un multiplicador de
|
|
64
|
+
encuadre independiente del tamaño del viewport y se conserva al compilar YAML
|
|
65
|
+
o Markdown.
|
|
66
|
+
|
|
33
67
|
Para compilar una fuente YAML durante el build:
|
|
34
68
|
|
|
35
69
|
```js
|
|
@@ -153,4 +187,4 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
|
|
|
153
187
|
|
|
154
188
|
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.
|
|
155
189
|
|
|
156
|
-
|
|
190
|
+
`row`, `grid`/`masonry` y `volume` producen posiciones deterministas. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría. El algoritmo `graph` sigue reservado: por ahora emite `GRAPH_LAYOUT_FALLBACK` y usa `grid`. La proyección y el enrutado avanzado de relaciones entre contenedores permanecen como líneas de desarrollo posteriores.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liquidcars/atlas-layout",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Declarative layout compiler for LiquidCars Atlas models",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
}
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
|
-
"test": "node --test test/index.test.mjs"
|
|
31
|
+
"test": "node --test test/index.test.mjs",
|
|
32
|
+
"prepack": "npm test"
|
|
32
33
|
},
|
|
33
34
|
"keywords": [
|
|
34
35
|
"liquidcars",
|
|
@@ -41,5 +42,10 @@
|
|
|
41
42
|
"license": "MIT",
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"yaml": "^2.8.1"
|
|
44
|
-
}
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"README.md",
|
|
48
|
+
"src",
|
|
49
|
+
"spec"
|
|
50
|
+
]
|
|
45
51
|
}
|
|
@@ -72,8 +72,41 @@ fit permits assisted layout to use the geometry's canonical size.
|
|
|
72
72
|
## Assisted layout
|
|
73
73
|
|
|
74
74
|
layout.algorithm accepts row, grid, volume or graph. direction accepts x, y or z.
|
|
75
|
-
Grid layouts also accept variant uniform or masonry
|
|
76
|
-
|
|
75
|
+
Grid layouts also accept variant uniform or masonry, optional columns and rows, and
|
|
76
|
+
a plane of xz (the default), xy or yz. Volume layouts additionally accept layers.
|
|
77
|
+
gap and padding are absolute numbers. justify accepts start, center, end,
|
|
78
|
+
space-between, space-around or space-evenly. These modes become distinct when a
|
|
79
|
+
row receives an explicit or constrained size. Distributed modes treat gap as the
|
|
80
|
+
minimum separation; padding defines the inner boundaries.
|
|
81
|
+
For row layouts, packing accepts preserve (the default) or compact. Compact packing
|
|
82
|
+
groups siblings connected by an alignment on the row axis, removes slots vacated by
|
|
83
|
+
those alignments, and reapplies gap between the resulting bands.
|
|
84
|
+
|
|
85
|
+
A layout may declare invisible groups under `layout.groups`. Each group has an `id`,
|
|
86
|
+
a list of immediate sibling `members`, and its own nested `layout`. The compiler
|
|
87
|
+
lays out the members as a temporary composite, permits sibling constraints to reference
|
|
88
|
+
the group id, then removes the group and emits only the real entities. Members must be
|
|
89
|
+
immediate siblings and may belong to only one group at that level. Group ids share the
|
|
90
|
+
layout scope with sibling ids. Groups have no geometry, style, selection state or
|
|
91
|
+
relation endpoints.
|
|
92
|
+
|
|
93
|
+
```yaml
|
|
94
|
+
layout:
|
|
95
|
+
algorithm: row
|
|
96
|
+
direction: y
|
|
97
|
+
packing: compact
|
|
98
|
+
groups:
|
|
99
|
+
- id: support-layer
|
|
100
|
+
members: [infrastructure, media]
|
|
101
|
+
layout:
|
|
102
|
+
algorithm: row
|
|
103
|
+
direction: x
|
|
104
|
+
justify: space-between
|
|
105
|
+
gap: 2
|
|
106
|
+
constraints:
|
|
107
|
+
- align: [top, support-layer, bottom]
|
|
108
|
+
edges: [left, front]
|
|
109
|
+
```
|
|
77
110
|
|
|
78
111
|
An entity weight influences its relative allocation. span can explicitly occupy
|
|
79
112
|
columns, rows and layers. A semantic style.aspect may be compact, wide, tall, deep or flat.
|
|
@@ -117,8 +150,11 @@ Optional fields include id, label, fromAnchor, toAnchor, priority and layout.
|
|
|
117
150
|
## Rendering
|
|
118
151
|
|
|
119
152
|
palette maps tokens to CSS colours. style.color or c can reference a token or direct
|
|
120
|
-
CSS colour. render can additionally set relationMode, selectionMode, toolbar
|
|
121
|
-
shellLabels
|
|
153
|
+
CSS colour. render can additionally set relationMode, selectionMode, toolbar,
|
|
154
|
+
shellLabels (the text-label contrast outline) and camera. An initial camera snapshot contains model-space
|
|
155
|
+
position and target vectors plus a positive, dimensionless zoom value. Position
|
|
156
|
+
and target determine the viewing direction; zoom preserves the intended framing
|
|
157
|
+
across viewport sizes. theme contains background, lighting and ui settings.
|
|
122
158
|
|
|
123
159
|
## Markdown blocks
|
|
124
160
|
|
package/src/index.js
CHANGED
|
@@ -418,8 +418,15 @@ function separateAlignmentOverlaps(placements, lockedAxes, requiredIds, spec, di
|
|
|
418
418
|
{ ownerId, entityId: key, axis: ["x", "y", "z"][axis] });
|
|
419
419
|
}
|
|
420
420
|
}
|
|
421
|
+
const relevant = placements.slice(0, index).filter(candidate =>
|
|
422
|
+
[0, 1, 2].every(otherAxis =>
|
|
423
|
+
otherAxis === axis ||
|
|
424
|
+
Math.abs(item.position[otherAxis] - candidate.position[otherAxis]) + 1e-9 <
|
|
425
|
+
(item.size[otherAxis] + candidate.size[otherAxis]) / 2
|
|
426
|
+
)
|
|
427
|
+
);
|
|
421
428
|
item.position = [...item.position];
|
|
422
|
-
item.position[axis] = Math.max(...
|
|
429
|
+
item.position[axis] = Math.max(...(relevant.length ? relevant : [blocker]).map(candidate =>
|
|
423
430
|
candidate.position[axis] + (item.size[axis] + candidate.size[axis]) / 2 + gap
|
|
424
431
|
));
|
|
425
432
|
}
|
|
@@ -460,16 +467,254 @@ function applyAlignmentConstraints(placements, constraints, diagnostics, ownerId
|
|
|
460
467
|
}
|
|
461
468
|
}
|
|
462
469
|
separateAlignmentOverlaps(placements, lockedAxes, requiredIds, spec, diagnostics, ownerId);
|
|
470
|
+
return lockedAxes;
|
|
463
471
|
}
|
|
464
472
|
|
|
465
|
-
function
|
|
473
|
+
function compactBandCrossAxis(items, primaryAxis, lockedAxes, gap) {
|
|
474
|
+
if (items.length < 2) return;
|
|
475
|
+
const axis = [0, 1, 2].find(candidate =>
|
|
476
|
+
candidate !== primaryAxis &&
|
|
477
|
+
items.some(item => !(lockedAxes.get(item.entity.id) || new Set()).has(candidate)) &&
|
|
478
|
+
Math.max(...items.map(item => item.position[candidate])) -
|
|
479
|
+
Math.min(...items.map(item => item.position[candidate])) > 1e-9
|
|
480
|
+
);
|
|
481
|
+
if (!Number.isInteger(axis)) return;
|
|
482
|
+
|
|
483
|
+
const ordered = [...items].sort((left, right) =>
|
|
484
|
+
left.position[axis] - right.position[axis] ||
|
|
485
|
+
items.indexOf(left) - items.indexOf(right)
|
|
486
|
+
);
|
|
487
|
+
const locked = item => (lockedAxes.get(item.entity.id) || new Set()).has(axis);
|
|
488
|
+
|
|
489
|
+
for (let index = 1; index < ordered.length; index++) {
|
|
490
|
+
const item = ordered[index];
|
|
491
|
+
if (locked(item)) continue;
|
|
492
|
+
const previous = ordered[index - 1];
|
|
493
|
+
const target = previous.position[axis] + (previous.size[axis] + item.size[axis]) / 2 + gap;
|
|
494
|
+
if (target < item.position[axis]) {
|
|
495
|
+
item.position = [...item.position];
|
|
496
|
+
item.position[axis] = target;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
for (let index = ordered.length - 2; index >= 0; index--) {
|
|
500
|
+
const item = ordered[index];
|
|
501
|
+
if (locked(item)) continue;
|
|
502
|
+
const next = ordered[index + 1];
|
|
503
|
+
const target = next.position[axis] - (next.size[axis] + item.size[axis]) / 2 - gap;
|
|
504
|
+
if (target > item.position[axis]) {
|
|
505
|
+
item.position = [...item.position];
|
|
506
|
+
item.position[axis] = target;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function compactRowBands(placements, constraints, spec, lockedAxes = new Map()) {
|
|
512
|
+
if (spec?.packing !== "compact" || spec?.algorithm !== "row" || placements.length < 2) return;
|
|
513
|
+
const axis = { x: 0, y: 1, z: 2 }[spec.direction] ?? 0;
|
|
514
|
+
const parent = placements.map((_, index) => index);
|
|
515
|
+
const byId = new Map(placements.map((item, index) => [item.entity.id, index]));
|
|
516
|
+
const find = index => {
|
|
517
|
+
while (parent[index] !== index) {
|
|
518
|
+
parent[index] = parent[parent[index]];
|
|
519
|
+
index = parent[index];
|
|
520
|
+
}
|
|
521
|
+
return index;
|
|
522
|
+
};
|
|
523
|
+
const union = (left, right) => {
|
|
524
|
+
const leftRoot = find(left);
|
|
525
|
+
const rightRoot = find(right);
|
|
526
|
+
if (leftRoot !== rightRoot) parent[rightRoot] = leftRoot;
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
for (const constraint of constraints) {
|
|
530
|
+
const ids = constraintIds(constraint?.align).filter(id => byId.has(id));
|
|
531
|
+
const requested = constraint?.edges ?? constraint?.edge;
|
|
532
|
+
const edges = Array.isArray(requested) ? requested : [requested];
|
|
533
|
+
if (ids.length < 2 || !edges.some(edge => ALIGNMENT_EDGES[edge]?.[0] === axis)) continue;
|
|
534
|
+
const first = byId.get(ids[0]);
|
|
535
|
+
for (const id of ids.slice(1)) union(first, byId.get(id));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
const bandsByRoot = new Map();
|
|
539
|
+
placements.forEach((item, index) => {
|
|
540
|
+
const root = find(index);
|
|
541
|
+
if (!bandsByRoot.has(root)) bandsByRoot.set(root, []);
|
|
542
|
+
bandsByRoot.get(root).push(item);
|
|
543
|
+
});
|
|
544
|
+
const bands = [...bandsByRoot.values()].sort((left, right) =>
|
|
545
|
+
placements.indexOf(left[0]) - placements.indexOf(right[0])
|
|
546
|
+
).map(items => {
|
|
547
|
+
const minimum = Math.min(...items.map(item => item.position[axis] - item.size[axis] / 2));
|
|
548
|
+
const maximum = Math.max(...items.map(item => item.position[axis] + item.size[axis] / 2));
|
|
549
|
+
return { items, center: (minimum + maximum) / 2, length: maximum - minimum };
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
const gap = Math.max(0, scalar(spec.gap, 1));
|
|
553
|
+
for (const band of bands) compactBandCrossAxis(band.items, axis, lockedAxes, gap);
|
|
554
|
+
const total = bands.reduce((sum, band) => sum + band.length, 0) + Math.max(0, bands.length - 1) * gap;
|
|
555
|
+
const orderSign = axis === 0 ? 1 : -1;
|
|
556
|
+
let cursor = -total / 2;
|
|
557
|
+
for (const band of bands) {
|
|
558
|
+
const target = orderSign * (cursor + band.length / 2);
|
|
559
|
+
const delta = target - band.center;
|
|
560
|
+
for (const item of band.items) {
|
|
561
|
+
item.position = [...item.position];
|
|
562
|
+
item.position[axis] += delta;
|
|
563
|
+
}
|
|
564
|
+
cursor += band.length + gap;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function layoutFunction(spec, diagnostics, ownerId) {
|
|
569
|
+
const algorithm = ALGORITHMS.has(spec?.algorithm) ? spec.algorithm : "grid";
|
|
570
|
+
if (!ALGORITHMS.has(spec?.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM",
|
|
571
|
+
`Unknown layout algorithm '${spec?.algorithm}', using grid.`, { entityId: ownerId });
|
|
572
|
+
if (algorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK",
|
|
573
|
+
`Graph layout for '${ownerId || "root"}' is reserved for a later compiler pass; using grid for now.`, { entityId: ownerId });
|
|
574
|
+
return algorithm === "row"
|
|
575
|
+
? rowLayout
|
|
576
|
+
: algorithm === "volume"
|
|
577
|
+
? volumeLayout
|
|
578
|
+
: gridLayout;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function materializeLayoutGroups(items, spec, diagnostics, ownerId) {
|
|
582
|
+
const definitions = Array.isArray(spec?.groups) ? spec.groups : [];
|
|
583
|
+
if (!definitions.length) return { items, groups: new Map() };
|
|
584
|
+
|
|
585
|
+
const itemById = new Map(items.map(item => [item.entity.id, item]));
|
|
586
|
+
const claimed = new Map();
|
|
587
|
+
const groups = new Map();
|
|
588
|
+
const validDefinitions = [];
|
|
589
|
+
|
|
590
|
+
for (const definition of definitions) {
|
|
591
|
+
const id = typeof definition?.id === "string" ? definition.id.trim() : "";
|
|
592
|
+
if (!id) {
|
|
593
|
+
diagnostic(diagnostics, "error", "LAYOUT_GROUP_ID_REQUIRED",
|
|
594
|
+
"A layout group requires a non-empty id.", { ownerId });
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
if (itemById.has(id) || groups.has(id)) {
|
|
598
|
+
diagnostic(diagnostics, "error", "DUPLICATE_LAYOUT_GROUP_ID",
|
|
599
|
+
`Layout group id '${id}' conflicts with another sibling or group.`, { ownerId, groupId: id });
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
groups.set(id, null);
|
|
603
|
+
validDefinitions.push({ ...definition, id });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const virtualByMember = new Map();
|
|
607
|
+
for (const definition of validDefinitions) {
|
|
608
|
+
const rawMembers = Array.isArray(definition.members) ? definition.members : [];
|
|
609
|
+
const members = [...new Set(rawMembers.filter(id => typeof id === "string" && id))];
|
|
610
|
+
const repeated = rawMembers.filter((id, index) => rawMembers.indexOf(id) !== index);
|
|
611
|
+
if (repeated.length) diagnostic(diagnostics, "error", "DUPLICATE_LAYOUT_GROUP_MEMBER",
|
|
612
|
+
`Layout group '${definition.id}' repeats members: ${[...new Set(repeated)].join(", ")}.`,
|
|
613
|
+
{ ownerId, groupId: definition.id, entityIds: [...new Set(repeated)] });
|
|
614
|
+
const unknown = members.filter(id => !itemById.has(id));
|
|
615
|
+
if (unknown.length) diagnostic(diagnostics, "error", "UNKNOWN_LAYOUT_GROUP_MEMBER",
|
|
616
|
+
`Layout group '${definition.id}' references entities outside the sibling group: ${unknown.join(", ")}.`,
|
|
617
|
+
{ ownerId, groupId: definition.id, entityIds: unknown });
|
|
618
|
+
const available = members.filter(id => itemById.has(id) && !claimed.has(id));
|
|
619
|
+
const duplicateClaims = members.filter(id => claimed.has(id));
|
|
620
|
+
if (duplicateClaims.length) diagnostic(diagnostics, "error", "LAYOUT_GROUP_MEMBER_ALREADY_CLAIMED",
|
|
621
|
+
`Layout group '${definition.id}' reuses members already assigned to another group: ${duplicateClaims.join(", ")}.`,
|
|
622
|
+
{ ownerId, groupId: definition.id, entityIds: duplicateClaims });
|
|
623
|
+
if (!available.length) {
|
|
624
|
+
groups.delete(definition.id);
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
for (const id of available) claimed.set(id, definition.id);
|
|
628
|
+
|
|
629
|
+
const groupSpec = definition.layout || { algorithm: "row", direction: "x", gap: 1, padding: 0 };
|
|
630
|
+
const memberItems = available.map(id => itemById.get(id));
|
|
631
|
+
const groupResult = constrainedLayout(
|
|
632
|
+
memberItems,
|
|
633
|
+
groupSpec,
|
|
634
|
+
definition,
|
|
635
|
+
diagnostics,
|
|
636
|
+
definition.id,
|
|
637
|
+
layoutFunction(groupSpec, diagnostics, definition.id)
|
|
638
|
+
);
|
|
639
|
+
const virtual = {
|
|
640
|
+
entity: { id: definition.id, type: "layout-group", __atlasVirtualGroup: true },
|
|
641
|
+
size: [...groupResult.size],
|
|
642
|
+
intrinsicSize: [...groupResult.size],
|
|
643
|
+
groupResult,
|
|
644
|
+
groupSpec,
|
|
645
|
+
groupSource: definition,
|
|
646
|
+
memberItems
|
|
647
|
+
};
|
|
648
|
+
groups.set(definition.id, virtual);
|
|
649
|
+
for (const id of available) virtualByMember.set(id, virtual);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const outerItems = [];
|
|
653
|
+
const inserted = new Set();
|
|
654
|
+
for (const item of items) {
|
|
655
|
+
const virtual = virtualByMember.get(item.entity.id);
|
|
656
|
+
if (!virtual) {
|
|
657
|
+
outerItems.push(item);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
if (!inserted.has(virtual.entity.id)) {
|
|
661
|
+
inserted.add(virtual.entity.id);
|
|
662
|
+
outerItems.push(virtual);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return { items: outerItems, groups };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function reflowLayoutGroups(items, diagnostics) {
|
|
669
|
+
for (const item of items) {
|
|
670
|
+
if (!item.entity.__atlasVirtualGroup || item.groupSpec?.algorithm !== "row" ||
|
|
671
|
+
!["start", "end", "space-between", "space-around", "space-evenly"].includes(item.groupSpec?.justify)) continue;
|
|
672
|
+
const expanded = item.size.some((value, axis) => value > item.intrinsicSize[axis] + 1e-9);
|
|
673
|
+
if (!expanded) continue;
|
|
674
|
+
item.groupResult = constrainedLayout(
|
|
675
|
+
item.memberItems,
|
|
676
|
+
item.groupSpec,
|
|
677
|
+
item.groupSource,
|
|
678
|
+
diagnostics,
|
|
679
|
+
item.entity.id,
|
|
680
|
+
layoutFunction(item.groupSpec, diagnostics, item.entity.id),
|
|
681
|
+
item.size
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function flattenLayoutGroups(placements) {
|
|
687
|
+
const flattened = [];
|
|
688
|
+
for (const placement of placements) {
|
|
689
|
+
if (!placement.entity.__atlasVirtualGroup) {
|
|
690
|
+
flattened.push(placement);
|
|
691
|
+
continue;
|
|
692
|
+
}
|
|
693
|
+
for (const member of placement.groupResult.positions) {
|
|
694
|
+
flattened.push({
|
|
695
|
+
...member,
|
|
696
|
+
position: member.position.map((value, axis) => value + placement.position[axis])
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return flattened;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
function constrainedLayout(items, spec, source, diagnostics, ownerId, layout, availableSize = null) {
|
|
704
|
+
const grouped = materializeLayoutGroups(items, spec, diagnostics, ownerId);
|
|
466
705
|
const constraints = layoutConstraints(source, spec);
|
|
467
|
-
applyEqualSizeConstraints(items, constraints, diagnostics, ownerId);
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
706
|
+
applyEqualSizeConstraints(grouped.items, constraints, diagnostics, ownerId);
|
|
707
|
+
reflowLayoutGroups(grouped.items, diagnostics);
|
|
708
|
+
const result = layout(grouped.items, spec, diagnostics, availableSize);
|
|
709
|
+
const lockedAxes = applyAlignmentConstraints(result.positions, constraints, diagnostics, ownerId, spec);
|
|
710
|
+
compactRowBands(result.positions, constraints, spec, lockedAxes);
|
|
711
|
+
const normalized = constraints.some(constraint => constraint?.align) || spec?.packing === "compact"
|
|
471
712
|
? boundsOf(result.positions, Math.max(0, scalar(spec.padding, 1)))
|
|
472
713
|
: result;
|
|
714
|
+
return {
|
|
715
|
+
...normalized,
|
|
716
|
+
positions: flattenLayoutGroups(normalized.positions)
|
|
717
|
+
};
|
|
473
718
|
}
|
|
474
719
|
|
|
475
720
|
function boundsOf(placements, padding) {
|
|
@@ -490,29 +735,75 @@ function boundsOf(placements, padding) {
|
|
|
490
735
|
return { positions, size: max.map((value, axis) => value - min[axis] + padding * 2) };
|
|
491
736
|
}
|
|
492
737
|
|
|
493
|
-
function
|
|
738
|
+
function boundsOfAssignedAxis(placements, padding, axis, assignedSize) {
|
|
739
|
+
if (!placements.length) return { positions: [], size: [padding * 2, padding * 2, padding * 2] };
|
|
740
|
+
const min = [Infinity, Infinity, Infinity];
|
|
741
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
742
|
+
for (const item of placements) {
|
|
743
|
+
for (let itemAxis = 0; itemAxis < 3; itemAxis++) {
|
|
744
|
+
min[itemAxis] = Math.min(min[itemAxis], item.position[itemAxis] - item.size[itemAxis] / 2);
|
|
745
|
+
max[itemAxis] = Math.max(max[itemAxis], item.position[itemAxis] + item.size[itemAxis] / 2);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
const required = max.map((value, itemAxis) => value - min[itemAxis] + padding * 2);
|
|
749
|
+
if (assignedSize + 1e-9 < required[axis]) return boundsOf(placements, padding);
|
|
750
|
+
const center = min.map((value, itemAxis) => (value + max[itemAxis]) / 2);
|
|
751
|
+
const positions = placements.map(item => ({
|
|
752
|
+
...item,
|
|
753
|
+
position: item.position.map((value, itemAxis) => {
|
|
754
|
+
const centered = itemAxis === axis ? value : value - center[itemAxis];
|
|
755
|
+
return Object.is(centered, -0) ? 0 : centered;
|
|
756
|
+
})
|
|
757
|
+
}));
|
|
758
|
+
required[axis] = assignedSize;
|
|
759
|
+
return { positions, size: required };
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function rowLayout(items, spec, diagnostics, availableSize = null) {
|
|
494
763
|
const direction = ["x", "y", "z"].includes(spec.direction) ? spec.direction : "x";
|
|
495
764
|
const axis = { x: 0, y: 1, z: 2 }[direction];
|
|
496
765
|
const gap = Math.max(0, scalar(spec.gap, 1));
|
|
497
766
|
const padding = Math.max(0, scalar(spec.padding, 1));
|
|
767
|
+
const justify = spec.justify || "center";
|
|
768
|
+
const validJustify = ["start", "center", "end", "space-between", "space-around", "space-evenly"];
|
|
769
|
+
if (!validJustify.includes(justify)) diagnostic(diagnostics, "warning", "UNKNOWN_JUSTIFY", `Unknown row justify '${spec.justify}'.`);
|
|
770
|
+
|
|
498
771
|
const minimumWeight = Math.min(...items.map(item => positiveNumber(item.entity.weight, 1)));
|
|
499
772
|
const lengths = items.map(item => item.size[axis] * Math.max(1, positiveNumber(item.entity.weight, 1) / minimumWeight));
|
|
500
|
-
const
|
|
773
|
+
const contentLength = lengths.reduce((sum, value) => sum + value, 0);
|
|
774
|
+
const minimumLength = contentLength + Math.max(0, items.length - 1) * gap;
|
|
775
|
+
const assignedSize = Array.isArray(availableSize) ? Math.max(0, scalar(availableSize[axis], 0)) : 0;
|
|
776
|
+
const availableLength = Math.max(0, assignedSize - padding * 2);
|
|
777
|
+
const allocatedLength = Math.max(minimumLength, availableLength || minimumLength);
|
|
778
|
+
const free = Math.max(0, allocatedLength - minimumLength);
|
|
779
|
+
|
|
780
|
+
let leading = justify === "end" ? free : justify === "center" ? free / 2 : 0;
|
|
781
|
+
let spacing = gap;
|
|
782
|
+
if (justify === "space-between") {
|
|
783
|
+
if (items.length > 1) spacing += free / (items.length - 1);
|
|
784
|
+
else leading = free / 2;
|
|
785
|
+
} else if (justify === "space-around" && items.length) {
|
|
786
|
+
spacing += free / items.length;
|
|
787
|
+
leading = free / (items.length * 2);
|
|
788
|
+
} else if (justify === "space-evenly") {
|
|
789
|
+
spacing += free / (items.length + 1);
|
|
790
|
+
leading = free / (items.length + 1);
|
|
791
|
+
}
|
|
792
|
+
|
|
501
793
|
const orderSign = axis === 0 ? 1 : -1;
|
|
502
|
-
let cursor = -
|
|
794
|
+
let cursor = -allocatedLength / 2 + leading;
|
|
503
795
|
const placements = items.map((item, index) => {
|
|
504
796
|
const length = lengths[index];
|
|
505
797
|
cursor += length / 2;
|
|
506
798
|
const position = [0, 0, 0];
|
|
507
799
|
position[axis] = cursor * orderSign;
|
|
508
|
-
cursor += length / 2 +
|
|
800
|
+
cursor += length / 2 + spacing;
|
|
509
801
|
const slotSize = item.size.map((value, itemAxis) => itemAxis === axis ? length : value);
|
|
510
802
|
return { ...item, size: sizeForSlot(item, slotSize), slotSize, position };
|
|
511
803
|
});
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return boundsOf(placements, padding);
|
|
804
|
+
return assignedSize > 0 && availableLength + 1e-9 >= minimumLength
|
|
805
|
+
? boundsOfAssignedAxis(placements, padding, axis, assignedSize)
|
|
806
|
+
: boundsOf(placements, padding);
|
|
516
807
|
}
|
|
517
808
|
|
|
518
809
|
function gridDimensions(items, spec, dimension) {
|
|
@@ -558,6 +849,12 @@ function markOccupied(occupied, start, span, dimension) {
|
|
|
558
849
|
}
|
|
559
850
|
}
|
|
560
851
|
|
|
852
|
+
const GRID_PLANES = Object.freeze({
|
|
853
|
+
xy: [0, 1, 2],
|
|
854
|
+
xz: [0, 2, 1],
|
|
855
|
+
yz: [2, 1, 0]
|
|
856
|
+
});
|
|
857
|
+
|
|
561
858
|
function packedGridLayout(items, spec, diagnostics, dimension = 2) {
|
|
562
859
|
const gap = Math.max(0, scalar(spec.gap, 1));
|
|
563
860
|
const padding = Math.max(0, scalar(spec.padding, 1));
|
|
@@ -586,17 +883,23 @@ function packedGridLayout(items, spec, diagnostics, dimension = 2) {
|
|
|
586
883
|
markOccupied(occupied, start, span, 2);
|
|
587
884
|
placements.push({ ...item, span, cell: start });
|
|
588
885
|
}
|
|
589
|
-
const
|
|
590
|
-
const
|
|
591
|
-
const
|
|
886
|
+
const [columnAxis, rowAxis, orthogonalAxis] = GRID_PLANES[spec.plane] || GRID_PLANES.xz;
|
|
887
|
+
const columnCell = Math.max(...placements.map(item => item.size[columnAxis] / item.span[0]), 1);
|
|
888
|
+
const rowCell = Math.max(...placements.map(item => item.size[rowAxis] / item.span[1]), 1);
|
|
889
|
+
const orthogonalCell = Math.max(...placements.map(item => item.size[orthogonalAxis]), 1);
|
|
592
890
|
for (const item of placements) {
|
|
593
|
-
const
|
|
594
|
-
const
|
|
595
|
-
const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (
|
|
596
|
-
const slotSize = [
|
|
891
|
+
const columnSize = item.span[0] * columnCell + (item.span[0] - 1) * gap;
|
|
892
|
+
const rowSize = item.span[1] * rowCell + (item.span[1] - 1) * gap;
|
|
893
|
+
const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (columnCell + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
|
|
894
|
+
const slotSize = [0, 0, 0];
|
|
895
|
+
slotSize[columnAxis] = columnSize;
|
|
896
|
+
slotSize[rowAxis] = rowSize;
|
|
897
|
+
slotSize[orthogonalAxis] = orthogonalCell;
|
|
597
898
|
item.size = sizeForSlot(item, slotSize);
|
|
598
899
|
item.slotSize = slotSize;
|
|
599
|
-
item.position = [
|
|
900
|
+
item.position = [0, 0, 0];
|
|
901
|
+
item.position[columnAxis] = item.cell[0] * (columnCell + gap) + columnSize / 2 + offset;
|
|
902
|
+
item.position[rowAxis] = -(item.cell[1] * (rowCell + gap) + rowSize / 2);
|
|
600
903
|
}
|
|
601
904
|
return boundsOf(placements, padding);
|
|
602
905
|
}
|
|
@@ -697,18 +1000,19 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
697
1000
|
const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
|
|
698
1001
|
if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
|
|
699
1002
|
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 });
|
|
1003
|
+
const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
|
|
700
1004
|
const result = constrainedLayout(childItems, spec, sourceEntity, diagnostics, entityId,
|
|
701
1005
|
algorithm === "row"
|
|
702
1006
|
? rowLayout
|
|
703
1007
|
: algorithm === "volume"
|
|
704
1008
|
? volumeLayout
|
|
705
|
-
: gridLayout
|
|
1009
|
+
: gridLayout,
|
|
1010
|
+
declared);
|
|
706
1011
|
for (const placement of result.positions) {
|
|
707
1012
|
const child = byId.get(placement.entity.id);
|
|
708
1013
|
child.p = placement.position;
|
|
709
1014
|
child.s = placement.size;
|
|
710
1015
|
}
|
|
711
|
-
const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
|
|
712
1016
|
entity.s = declared ? [Math.max(declared[0], result.size[0]), Math.max(declared[1], result.size[1]), Math.max(declared[2], result.size[2])] : result.size;
|
|
713
1017
|
if (declared && sourceEntity.sizeMode === "fixed" && result.size.some((value, axis) => value > declared[axis])) {
|
|
714
1018
|
diagnostic(diagnostics, "error", "FIXED_LAYOUT_OVERFLOW", `Content exceeds fixed container '${entity.id}'.`, { entityId: entity.id, declaredSize: declared, requiredSize: result.size });
|
|
@@ -746,6 +1050,7 @@ export function compileAtlasModel(input, options = {}) {
|
|
|
746
1050
|
relations,
|
|
747
1051
|
layout: source.layout || undefined,
|
|
748
1052
|
constraints: source.constraints || undefined,
|
|
1053
|
+
render: source.render || undefined,
|
|
749
1054
|
diagnostics
|
|
750
1055
|
};
|
|
751
1056
|
if (options.throwOnFatal && diagnostics.some(item => item.level === "fatal")) throw new Error(diagnostics.filter(item => item.level === "fatal").map(item => item.message).join("\n"));
|
package/src/spec.js
CHANGED
|
@@ -70,16 +70,28 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
|
|
|
70
70
|
algorithm: field("Spatial strategy.", ["row", "grid", "volume", "graph"]),
|
|
71
71
|
direction: field("Primary semantic axis.", ["x", "y", "z"]),
|
|
72
72
|
variant: field("Regular or staggered grid cells.", ["uniform", "masonry"]),
|
|
73
|
+
plane: field("Axes used by a two-dimensional grid.", ["xz", "xy", "yz"]),
|
|
74
|
+
packing: field("Preserve initial slots or compact free space after constraints.", ["preserve", "compact"]),
|
|
75
|
+
groups: field("Invisible sibling groups used only during layout.", null, { context: "layoutGroup", sequence: true }),
|
|
73
76
|
columns: field("Explicit column count.", null, { type: "integer" }),
|
|
74
77
|
rows: field("Explicit row count.", null, { type: "integer" }),
|
|
75
78
|
layers: field("Explicit volume layer count.", null, { type: "integer" }),
|
|
76
79
|
gap: field("Spacing between children.", null, { type: "number" }),
|
|
77
80
|
padding: field("Spacing to the container boundary.", null, { type: "number" }),
|
|
78
|
-
justify: field("Row alignment.", ["start", "center", "end"]),
|
|
81
|
+
justify: field("Row alignment or distribution within an assigned size.", ["start", "center", "end", "space-between", "space-around", "space-evenly"]),
|
|
79
82
|
stagger: field("Optional masonry offset.", null, { context: "stagger" }),
|
|
80
83
|
constraints: field("Equal-size and edge-alignment rules for this layout's siblings.", null, { context: "constraint", sequence: true })
|
|
81
84
|
})
|
|
82
85
|
}),
|
|
86
|
+
layoutGroup: freeze({
|
|
87
|
+
label: "Virtual layout group",
|
|
88
|
+
properties: freeze({
|
|
89
|
+
id: field("Identifier referenced by sibling constraints.", null, { type: "string", required: true }),
|
|
90
|
+
members: field("Immediate sibling entity ids grouped for layout.", null, { type: "string-list", required: true }),
|
|
91
|
+
layout: field("Internal layout of the invisible group.", null, { context: "layout" }),
|
|
92
|
+
constraints: field("Constraints between members of this group.", null, { context: "constraint", sequence: true })
|
|
93
|
+
})
|
|
94
|
+
}),
|
|
83
95
|
constraint: freeze({
|
|
84
96
|
label: "Sibling constraint",
|
|
85
97
|
properties: freeze({
|
|
@@ -124,7 +136,16 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
|
|
|
124
136
|
relationMode: field("Visible relation set.", ["all", "selected", "none"]),
|
|
125
137
|
selectionMode: field("Selection ownership.", ["internal", "event"]),
|
|
126
138
|
toolbar: field("Built-in toolbar visibility.", [true, false]),
|
|
127
|
-
shellLabels: field("
|
|
139
|
+
shellLabels: field("Text-label contrast outline.", [true, false]),
|
|
140
|
+
camera: field("Initial camera snapshot in model-space coordinates.", null, { context: "camera" })
|
|
141
|
+
})
|
|
142
|
+
}),
|
|
143
|
+
camera: freeze({
|
|
144
|
+
label: "Initial camera",
|
|
145
|
+
properties: freeze({
|
|
146
|
+
position: field("Camera position in model-space coordinates.", null, { type: "vector", required: true }),
|
|
147
|
+
target: field("Model-space point observed by the camera.", null, { type: "vector", required: true }),
|
|
148
|
+
zoom: field("Dimensionless framing multiplier.", null, { type: "number", required: true })
|
|
128
149
|
})
|
|
129
150
|
}),
|
|
130
151
|
span: freeze({
|
|
@@ -159,11 +180,13 @@ export function atlasLayoutContextForPath(path = []) {
|
|
|
159
180
|
["children", "entity"],
|
|
160
181
|
["relations", "relation"],
|
|
161
182
|
["layout", "layout"],
|
|
183
|
+
["groups", "layoutGroup"],
|
|
162
184
|
["constraints", "constraint"],
|
|
163
185
|
["style", "style"],
|
|
164
186
|
["palette", "palette"],
|
|
165
187
|
["theme", "theme"],
|
|
166
188
|
["render", "render"],
|
|
189
|
+
["camera", "camera"],
|
|
167
190
|
["span", "span"],
|
|
168
191
|
["stagger", "stagger"],
|
|
169
192
|
["label", "label"],
|
|
@@ -185,6 +208,17 @@ export function atlasLayoutProperty(context, name) {
|
|
|
185
208
|
return context?.properties?.[name] || null;
|
|
186
209
|
}
|
|
187
210
|
|
|
211
|
+
export function atlasLayoutPropertyForPath(path = []) {
|
|
212
|
+
const names = Array.isArray(path) ? path : [];
|
|
213
|
+
if (!names.length) return null;
|
|
214
|
+
const context = atlasLayoutContextForPath(names.slice(0, -1));
|
|
215
|
+
return atlasLayoutProperty(context, names.at(-1));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function atlasLayoutSequenceForPath(path = []) {
|
|
219
|
+
return Boolean(atlasLayoutPropertyForPath(path)?.sequence);
|
|
220
|
+
}
|
|
221
|
+
|
|
188
222
|
const ABSOLUTE_ENTITY_PROPERTIES = new Set(["parent", "span", "size", "sizeMode", "p", "s"]);
|
|
189
223
|
|
|
190
224
|
export function atlasLayoutPropertyKind(context, name) {
|
|
Binary file
|
package/test/index.test.mjs
DELETED
|
@@ -1,478 +0,0 @@
|
|
|
1
|
-
import test from "node:test";
|
|
2
|
-
import assert from "node:assert/strict";
|
|
3
|
-
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { compileAtlasMarkdown, compileAtlasMarkdownDocument, compileAtlasYaml, parseAtlasMarkdown, parseAtlasMarkdownDocument } from "../src/yaml.js";
|
|
7
|
-
import { compileAtlasModel } from "../src/index.js";
|
|
8
|
-
import atlasLayout from "../src/vite.js";
|
|
9
|
-
|
|
10
|
-
function ids(model) {
|
|
11
|
-
return new Map(model.entities.map(entity => [entity.id, entity]));
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function entitiesOverlap(a, b) {
|
|
15
|
-
return [0, 1, 2].every(axis =>
|
|
16
|
-
Math.abs(a.p[axis] - b.p[axis]) < (a.s[axis] + b.s[axis]) / 2 - 1e-9
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
test("normalizes nested entities and inherits colors", () => {
|
|
21
|
-
const model = compileAtlasModel({
|
|
22
|
-
entities: [{
|
|
23
|
-
id: "container",
|
|
24
|
-
type: "container",
|
|
25
|
-
style: { color: "top" },
|
|
26
|
-
layout: { algorithm: "grid", columns: 2 },
|
|
27
|
-
children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "sphere", style: { color: "warm" } }]
|
|
28
|
-
}],
|
|
29
|
-
render: { palette: { top: "#00f", warm: "#f80", default_color: "#ccc" } }
|
|
30
|
-
});
|
|
31
|
-
const byId = ids(model);
|
|
32
|
-
assert.equal(byId.get("container").type, "container");
|
|
33
|
-
assert.equal(byId.get("a").type, "item");
|
|
34
|
-
assert.equal(byId.get("a").parent, "container");
|
|
35
|
-
assert.equal(byId.get("a").c, "top");
|
|
36
|
-
assert.equal(byId.get("b").c, "warm");
|
|
37
|
-
assert.deepEqual(byId.get("container").p, [0, 0, 0]);
|
|
38
|
-
assert.equal(model.diagnostics.length, 0);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("accepts flat parent references as well as nested children", () => {
|
|
42
|
-
const model = compileAtlasModel({
|
|
43
|
-
entities: [
|
|
44
|
-
{ id: "parent", type: "container", style: { color: "top" }, layout: { algorithm: "row", direction: "x" } },
|
|
45
|
-
{ id: "child", parent: "parent", geometry: "box" }
|
|
46
|
-
],
|
|
47
|
-
render: { palette: { top: "#00f" } }
|
|
48
|
-
});
|
|
49
|
-
const byId = ids(model);
|
|
50
|
-
assert.equal(byId.get("parent").type, "container");
|
|
51
|
-
assert.equal(byId.get("child").type, "item");
|
|
52
|
-
assert.equal(byId.get("child").parent, "parent");
|
|
53
|
-
assert.equal(byId.get("child").c, "top");
|
|
54
|
-
assert.equal(model.diagnostics.length, 0);
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
test("requires an explicit container type for nested children", () => {
|
|
59
|
-
const model = compileAtlasModel({
|
|
60
|
-
entities: [{
|
|
61
|
-
id: "root",
|
|
62
|
-
children: [{ id: "child", geometry: "box" }]
|
|
63
|
-
}]
|
|
64
|
-
});
|
|
65
|
-
assert.equal(model.diagnostics[0].code, "CONTAINER_TYPE_REQUIRED_FOR_CHILDREN");
|
|
66
|
-
assert.equal(model.diagnostics[0].level, "fatal");
|
|
67
|
-
assert.equal(ids(model).get("root").type, "container");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("rejects custom geometry on containers", () => {
|
|
71
|
-
const model = compileAtlasModel({
|
|
72
|
-
entities: [{
|
|
73
|
-
id: "root",
|
|
74
|
-
type: "container",
|
|
75
|
-
geometry: "infra.cloud",
|
|
76
|
-
children: [{ id: "child", geometry: "box" }]
|
|
77
|
-
}]
|
|
78
|
-
}, {
|
|
79
|
-
geometryCatalog: [{ id: "infra.cloud", canonicalSize: [3, 2, 3] }]
|
|
80
|
-
});
|
|
81
|
-
assert.equal(model.diagnostics[0].code, "CUSTOM_GEOMETRY_ON_CONTAINER");
|
|
82
|
-
assert.equal(model.diagnostics[0].level, "fatal");
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("accepts primitive geometry on explicit containers", () => {
|
|
86
|
-
const model = compileAtlasModel({
|
|
87
|
-
entities: [{
|
|
88
|
-
id: "root",
|
|
89
|
-
type: "container",
|
|
90
|
-
geometry: "geo.box",
|
|
91
|
-
children: [{ id: "child", geometry: "sphere" }]
|
|
92
|
-
}]
|
|
93
|
-
});
|
|
94
|
-
assert.equal(ids(model).get("root").type, "container");
|
|
95
|
-
assert.equal(model.diagnostics.length, 0);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test("normalizes shell as a compatible container alias and infers leaf items", () => {
|
|
99
|
-
const model = compileAtlasModel({
|
|
100
|
-
entities: [{
|
|
101
|
-
id: "root",
|
|
102
|
-
type: "shell",
|
|
103
|
-
children: [{ id: "leaf", geometry: "infra.cloud" }]
|
|
104
|
-
}]
|
|
105
|
-
}, {
|
|
106
|
-
geometryCatalog: [{ id: "infra.cloud", canonicalSize: [3, 2, 3] }]
|
|
107
|
-
});
|
|
108
|
-
assert.equal(ids(model).get("root").type, "container");
|
|
109
|
-
assert.equal(ids(model).get("leaf").type, "item");
|
|
110
|
-
assert.equal(model.diagnostics.length, 0);
|
|
111
|
-
});
|
|
112
|
-
test("row y places the first item above the next item", () => {
|
|
113
|
-
const model = compileAtlasModel({
|
|
114
|
-
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
115
|
-
entities: [{ id: "top", geometry: "box" }, { id: "bottom", geometry: "box" }]
|
|
116
|
-
});
|
|
117
|
-
const byId = ids(model);
|
|
118
|
-
assert.ok(byId.get("top").p[1] > byId.get("bottom").p[1]);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("grid starts at the front and advances toward the back", () => {
|
|
122
|
-
const model = compileAtlasModel({
|
|
123
|
-
layout: { algorithm: "grid", columns: 1, gap: 1 },
|
|
124
|
-
entities: [{ id: "front", geometry: "box" }, { id: "back", geometry: "box" }]
|
|
125
|
-
});
|
|
126
|
-
const byId = ids(model);
|
|
127
|
-
assert.ok(byId.get("front").p[2] > byId.get("back").p[2]);
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test("grid preserves canonical proportions inside non-cubic cells", () => {
|
|
131
|
-
const model = compileAtlasModel({
|
|
132
|
-
entities: [{ id: "sphere", geometry: "sphere" }, { id: "wide", geometry: "box", weight: 2 }],
|
|
133
|
-
layout: { algorithm: "grid", columns: 2, gap: 1 }
|
|
134
|
-
});
|
|
135
|
-
const sphere = ids(model).get("sphere");
|
|
136
|
-
assert.ok(Math.abs(sphere.s[0] - sphere.s[1]) < 1e-9);
|
|
137
|
-
assert.ok(Math.abs(sphere.s[1] - sphere.s[2]) < 1e-9);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
test("masonry offsets alternate rows", () => {
|
|
141
|
-
const model = compileAtlasModel({
|
|
142
|
-
layout: { algorithm: "grid", variant: "masonry", columns: 2, stagger: { mode: "alternate", offset: "half-cell" } },
|
|
143
|
-
entities: ["a", "b", "c", "d"].map(id => ({ id, geometry: "box" }))
|
|
144
|
-
});
|
|
145
|
-
const byId = ids(model);
|
|
146
|
-
assert.notEqual(byId.get("a").p[0], byId.get("c").p[0]);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("volume distributes elements from bottom to top", () => {
|
|
150
|
-
const model = compileAtlasModel({
|
|
151
|
-
layout: { algorithm: "volume", columns: 1, rows: 1, layers: 2, gap: 1 },
|
|
152
|
-
entities: [{ id: "bottom", geometry: "box" }, { id: "top", geometry: "box" }]
|
|
153
|
-
});
|
|
154
|
-
const byId = ids(model);
|
|
155
|
-
assert.ok(byId.get("top").p[1] > byId.get("bottom").p[1]);
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("invalid relation endpoints produce recoverable diagnostics", () => {
|
|
159
|
-
const model = compileAtlasModel({
|
|
160
|
-
entities: [{ id: "a", geometry: "box" }],
|
|
161
|
-
relations: [{ from: "a", to: "missing", label: "broken" }]
|
|
162
|
-
});
|
|
163
|
-
assert.equal(model.relations.length, 0);
|
|
164
|
-
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_ENDPOINT");
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("normalizes relation modes and keeps optional relation ids", () => {
|
|
168
|
-
const model = compileAtlasModel({
|
|
169
|
-
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }, { id: "c", geometry: "box" }, { id: "d", geometry: "box" }],
|
|
170
|
-
relations: [
|
|
171
|
-
{ id: "feed", from: "a", to: "b", mode: "bidirectional" },
|
|
172
|
-
{ from: "b", to: "c", visual: "interrupted" },
|
|
173
|
-
["c", "d", "legacy shorthand", "two-way"]
|
|
174
|
-
]
|
|
175
|
-
});
|
|
176
|
-
assert.deepEqual(model.relations.map(relation => relation.mode), ["bidirectional", "broken", "bidirectional"]);
|
|
177
|
-
assert.equal(model.relations[0].id, "feed");
|
|
178
|
-
assert.equal(model.diagnostics.length, 0);
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
test("invalid relation modes fall back to directed with a warning", () => {
|
|
182
|
-
const model = compileAtlasModel({
|
|
183
|
-
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }],
|
|
184
|
-
relations: [{ from: "a", to: "b", mode: "sideways" }]
|
|
185
|
-
});
|
|
186
|
-
assert.equal(model.relations[0].mode, "directed");
|
|
187
|
-
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_MODE");
|
|
188
|
-
assert.equal(model.diagnostics[0].fallback, "directed");
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
test("fixed containers report overflow without hiding children", () => {
|
|
192
|
-
const model = compileAtlasModel({
|
|
193
|
-
entities: [{
|
|
194
|
-
id: "fixed",
|
|
195
|
-
type: "container",
|
|
196
|
-
size: [1, 1, 1],
|
|
197
|
-
sizeMode: "fixed",
|
|
198
|
-
layout: { algorithm: "row", gap: 1, padding: 1 },
|
|
199
|
-
children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }]
|
|
200
|
-
}]
|
|
201
|
-
});
|
|
202
|
-
assert.equal(model.diagnostics[0].code, "FIXED_LAYOUT_OVERFLOW");
|
|
203
|
-
assert.equal(model.entities.length, 3);
|
|
204
|
-
assert.equal(ids(model).get("fixed").layoutError, "overflow");
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
test("equal-size constraints use the largest intrinsic sibling dimensions", () => {
|
|
208
|
-
const model = compileAtlasModel({
|
|
209
|
-
constraints: [{
|
|
210
|
-
equal: ["wide", "small", "deep"],
|
|
211
|
-
dimensions: ["width", "height", "depth"],
|
|
212
|
-
strength: "preferred"
|
|
213
|
-
}],
|
|
214
|
-
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
215
|
-
entities: [
|
|
216
|
-
{ id: "wide", type: "container", layout: { algorithm: "row" }, children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "sphere" }] },
|
|
217
|
-
{ id: "small", type: "container", children: [{ id: "c", geometry: "box" }] },
|
|
218
|
-
{ id: "deep", type: "container", children: [{ id: "d", geometry: "pyramid-square" }] }
|
|
219
|
-
]
|
|
220
|
-
});
|
|
221
|
-
const byId = ids(model);
|
|
222
|
-
assert.deepEqual(byId.get("wide").s, byId.get("small").s);
|
|
223
|
-
assert.deepEqual(byId.get("small").s, byId.get("deep").s);
|
|
224
|
-
assert.equal(model.diagnostics.length, 0);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
test("alignment constraints use stable model-space boundary aliases", () => {
|
|
228
|
-
const model = compileAtlasModel({
|
|
229
|
-
constraints: [{
|
|
230
|
-
align: ["a", "b"],
|
|
231
|
-
edges: ["left", "front"],
|
|
232
|
-
strength: "required"
|
|
233
|
-
}],
|
|
234
|
-
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
235
|
-
entities: [
|
|
236
|
-
{ id: "a", geometry: "box", size: [4, 1, 2] },
|
|
237
|
-
{ id: "b", geometry: "box", size: [2, 1, 4] }
|
|
238
|
-
]
|
|
239
|
-
});
|
|
240
|
-
const byId = ids(model);
|
|
241
|
-
assert.equal(byId.get("a").p[0] - byId.get("a").s[0] / 2, byId.get("b").p[0] - byId.get("b").s[0] / 2);
|
|
242
|
-
assert.equal(byId.get("a").p[2] + byId.get("a").s[2] / 2, byId.get("b").p[2] + byId.get("b").s[2] / 2);
|
|
243
|
-
});
|
|
244
|
-
|
|
245
|
-
test("alignment preserves non-overlap when it targets the layout axis", () => {
|
|
246
|
-
const model = compileAtlasModel({
|
|
247
|
-
constraints: [{ align: ["a", "b", "c"], edges: ["centerY"], strength: "preferred" }],
|
|
248
|
-
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
249
|
-
entities: [
|
|
250
|
-
{ id: "a", geometry: "box", size: [4, 2, 2] },
|
|
251
|
-
{ id: "b", geometry: "sphere", size: [3, 3, 3] },
|
|
252
|
-
{ id: "c", geometry: "box", size: [2, 4, 2] }
|
|
253
|
-
]
|
|
254
|
-
});
|
|
255
|
-
const byId = ids(model);
|
|
256
|
-
assert.equal(byId.get("a").p[1], byId.get("b").p[1]);
|
|
257
|
-
assert.equal(byId.get("b").p[1], byId.get("c").p[1]);
|
|
258
|
-
assert.equal(entitiesOverlap(byId.get("a"), byId.get("b")), false);
|
|
259
|
-
assert.equal(entitiesOverlap(byId.get("a"), byId.get("c")), false);
|
|
260
|
-
assert.equal(entitiesOverlap(byId.get("b"), byId.get("c")), false);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
test("changing alignment recompiles from a clean packed layout", () => {
|
|
264
|
-
const source = {
|
|
265
|
-
constraints: [{ align: ["a", "b"], edges: ["centerY"], strength: "preferred" }],
|
|
266
|
-
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
267
|
-
entities: [
|
|
268
|
-
{ id: "a", geometry: "box", size: [4, 2, 2] },
|
|
269
|
-
{ id: "b", geometry: "box", size: [2, 2, 2] }
|
|
270
|
-
]
|
|
271
|
-
};
|
|
272
|
-
const first = compileAtlasModel(source);
|
|
273
|
-
const second = compileAtlasModel({
|
|
274
|
-
...first,
|
|
275
|
-
constraints: [{ align: ["a", "b"], edges: ["centerX"], strength: "preferred" }]
|
|
276
|
-
});
|
|
277
|
-
const byId = ids(second);
|
|
278
|
-
assert.equal(byId.get("a").p[0], byId.get("b").p[0]);
|
|
279
|
-
assert.equal(entitiesOverlap(byId.get("a"), byId.get("b")), false);
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
test("constraints reject references outside their sibling group", () => {
|
|
283
|
-
const model = compileAtlasModel({
|
|
284
|
-
constraints: [{ equal: ["a", "missing"], edge: "width", strength: "required" }],
|
|
285
|
-
entities: [{ id: "a", geometry: "box" }]
|
|
286
|
-
});
|
|
287
|
-
assert.equal(model.diagnostics[0].code, "UNKNOWN_CONSTRAINT_ENTITY");
|
|
288
|
-
assert.equal(model.diagnostics[0].level, "error");
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
test("graph layout is reported as a recoverable fallback", () => {
|
|
292
|
-
const model = compileAtlasModel({
|
|
293
|
-
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }],
|
|
294
|
-
layout: { algorithm: "graph" },
|
|
295
|
-
relations: [{ from: "a", to: "b" }]
|
|
296
|
-
});
|
|
297
|
-
assert.equal(model.diagnostics[0].code, "GRAPH_LAYOUT_FALLBACK");
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
test("compiles an Atlas YAML document at build time", () => {
|
|
301
|
-
const model = compileAtlasYaml(`
|
|
302
|
-
version: 1
|
|
303
|
-
render:
|
|
304
|
-
palette:
|
|
305
|
-
top: "#00f"
|
|
306
|
-
entities:
|
|
307
|
-
- id: root
|
|
308
|
-
type: container
|
|
309
|
-
layout:
|
|
310
|
-
algorithm: row
|
|
311
|
-
direction: x
|
|
312
|
-
children:
|
|
313
|
-
- id: child
|
|
314
|
-
geometry: sphere
|
|
315
|
-
style:
|
|
316
|
-
color: top
|
|
317
|
-
relations: []
|
|
318
|
-
`);
|
|
319
|
-
const child = ids(model).get("child");
|
|
320
|
-
assert.deepEqual(child.s, [2.2, 2.2, 2.2]);
|
|
321
|
-
assert.equal(child.c, "top");
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
test("extracts a full Atlas block or split model/layout/render/relations blocks from Markdown", () => {
|
|
325
|
-
const full = parseAtlasMarkdown("# Demo\n\n```atlas\nentities:\n - id: a\n geometry: box\n```\n");
|
|
326
|
-
assert.equal(full.entities[0].id, "a");
|
|
327
|
-
|
|
328
|
-
const markdown = [
|
|
329
|
-
"# Layered model",
|
|
330
|
-
"",
|
|
331
|
-
"```atlas-model",
|
|
332
|
-
"entities:",
|
|
333
|
-
" - id: a",
|
|
334
|
-
" geometry: box",
|
|
335
|
-
"```",
|
|
336
|
-
"",
|
|
337
|
-
"```atlas-layout",
|
|
338
|
-
"algorithm: row",
|
|
339
|
-
"direction: x",
|
|
340
|
-
"```",
|
|
341
|
-
"",
|
|
342
|
-
"```atlas-render",
|
|
343
|
-
"palette:",
|
|
344
|
-
" default_color: \"#abc\"",
|
|
345
|
-
"```",
|
|
346
|
-
"",
|
|
347
|
-
"```atlas-relations",
|
|
348
|
-
"- from: a",
|
|
349
|
-
" to: missing",
|
|
350
|
-
" mode: broken",
|
|
351
|
-
"```",
|
|
352
|
-
""
|
|
353
|
-
].join("\n");
|
|
354
|
-
const model = compileAtlasMarkdown(markdown);
|
|
355
|
-
assert.equal(model.entities.length, 1);
|
|
356
|
-
assert.deepEqual(model.entities[0].p, [0, 0, 0]);
|
|
357
|
-
assert.equal(model.palette.default_color, "#abc");
|
|
358
|
-
assert.equal(model.relations.length, 0);
|
|
359
|
-
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_ENDPOINT");
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
test("parses and compiles multiple named Atlas models in one Markdown document", () => {
|
|
363
|
-
const markdown = [
|
|
364
|
-
"# Two diagrams",
|
|
365
|
-
"",
|
|
366
|
-
"```atlas overview",
|
|
367
|
-
"entities:",
|
|
368
|
-
" - id: source",
|
|
369
|
-
" geometry: sphere",
|
|
370
|
-
"```",
|
|
371
|
-
"",
|
|
372
|
-
"Some explanatory text.",
|
|
373
|
-
"",
|
|
374
|
-
"```atlas process",
|
|
375
|
-
"entities:",
|
|
376
|
-
" - id: rules",
|
|
377
|
-
" geometry: prism",
|
|
378
|
-
"```",
|
|
379
|
-
""
|
|
380
|
-
].join("\n");
|
|
381
|
-
|
|
382
|
-
const parsed = parseAtlasMarkdownDocument(markdown);
|
|
383
|
-
assert.deepEqual(Object.keys(parsed.models), ["overview", "process"]);
|
|
384
|
-
assert.equal(parsed.defaultModel, "overview");
|
|
385
|
-
assert.equal(parsed.models.process.entities[0].id, "rules");
|
|
386
|
-
|
|
387
|
-
const compiled = compileAtlasMarkdownDocument(markdown);
|
|
388
|
-
assert.deepEqual(Object.keys(compiled.models), ["overview", "process"]);
|
|
389
|
-
assert.equal(compiled.models.overview.entities[0].s[0], 2.2);
|
|
390
|
-
assert.throws(() => parseAtlasMarkdown(markdown), /multiple models/);
|
|
391
|
-
assert.throws(() => compileAtlasMarkdown(markdown), /multiple models/);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
test("associates named split Markdown blocks with the same model", () => {
|
|
395
|
-
const markdown = [
|
|
396
|
-
"```atlas-model overview",
|
|
397
|
-
"entities:",
|
|
398
|
-
" - id: a",
|
|
399
|
-
" geometry: box",
|
|
400
|
-
" - id: b",
|
|
401
|
-
" geometry: sphere",
|
|
402
|
-
"```",
|
|
403
|
-
"",
|
|
404
|
-
"```atlas-relations overview",
|
|
405
|
-
"- from: a",
|
|
406
|
-
" to: b",
|
|
407
|
-
" mode: bidirectional",
|
|
408
|
-
"```",
|
|
409
|
-
"",
|
|
410
|
-
"```atlas-model details",
|
|
411
|
-
"entities:",
|
|
412
|
-
" - id: c",
|
|
413
|
-
" geometry: cone",
|
|
414
|
-
"```",
|
|
415
|
-
""
|
|
416
|
-
].join("\n");
|
|
417
|
-
const document = compileAtlasMarkdownDocument(markdown, { defaultModel: "details" });
|
|
418
|
-
assert.equal(document.defaultModel, "details");
|
|
419
|
-
assert.equal(document.models.overview.relations[0].mode, "bidirectional");
|
|
420
|
-
assert.equal(document.models.details.entities[0].id, "c");
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test("uses canonical sizes supplied by external geometry catalogs", () => {
|
|
424
|
-
const model = compileAtlasModel({
|
|
425
|
-
entities: [{ id: "server", geometry: "infra.server" }]
|
|
426
|
-
}, {
|
|
427
|
-
geometryCatalog: [{ id: "infra.server", aliases: ["server-rack"], canonicalSize: [3, 2, 4] }]
|
|
428
|
-
});
|
|
429
|
-
assert.deepEqual(model.entities[0].s, [3, 2, 4]);
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
test("preserves named geometry anchors on relations", () => {
|
|
433
|
-
const model = compileAtlasModel({
|
|
434
|
-
entities: [{ id: "a" }, { id: "b" }],
|
|
435
|
-
relations: [{ from: "a", to: "b", fromAnchor: "network", to_anchor: "input" }]
|
|
436
|
-
});
|
|
437
|
-
assert.equal(model.relations[0].fromAnchor, "network");
|
|
438
|
-
assert.equal(model.relations[0].toAnchor, "input");
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
test("Vite plugin turns an Atlas YAML import into a JS module", async () => {
|
|
442
|
-
const directory = await mkdtemp(join(tmpdir(), "atlas-layout-vite-"));
|
|
443
|
-
const sourcePath = join(directory, "model.atlas.yaml");
|
|
444
|
-
await writeFile(sourcePath, "entities:\n - id: source\n geometry: sphere\n", "utf8");
|
|
445
|
-
const plugin = atlasLayout();
|
|
446
|
-
const resolved = plugin.resolveId("./model.atlas.yaml", join(directory, "main.js"));
|
|
447
|
-
const warnings = [];
|
|
448
|
-
const code = await plugin.load.call({ warn: message => warnings.push(message) }, resolved);
|
|
449
|
-
assert.match(code, /export default/);
|
|
450
|
-
assert.match(code, /source/);
|
|
451
|
-
assert.equal(warnings.length, 0);
|
|
452
|
-
assert.equal((await readFile(sourcePath, "utf8")).includes("entities:"), true);
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
test("Vite plugin exposes multiple named Markdown models as a document", async () => {
|
|
456
|
-
const directory = await mkdtemp(join(tmpdir(), "atlas-layout-vite-multi-"));
|
|
457
|
-
const sourcePath = join(directory, "models.atlas.md");
|
|
458
|
-
await writeFile(sourcePath, [
|
|
459
|
-
"```atlas first",
|
|
460
|
-
"entities:",
|
|
461
|
-
" - id: first",
|
|
462
|
-
" geometry: box",
|
|
463
|
-
"```",
|
|
464
|
-
"",
|
|
465
|
-
"```atlas second",
|
|
466
|
-
"entities:",
|
|
467
|
-
" - id: second",
|
|
468
|
-
" geometry: sphere",
|
|
469
|
-
"```",
|
|
470
|
-
""
|
|
471
|
-
].join("\n"), "utf8");
|
|
472
|
-
const plugin = atlasLayout();
|
|
473
|
-
const resolved = plugin.resolveId("./models.atlas.md", join(directory, "main.js"));
|
|
474
|
-
const code = await plugin.load.call({ warn() {} }, resolved);
|
|
475
|
-
assert.match(code, /models/);
|
|
476
|
-
assert.match(code, /first/);
|
|
477
|
-
assert.match(code, /second/);
|
|
478
|
-
});
|