@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 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
- Esta es una primera implementación del motor. `row`, `grid`/`masonry` y `volume` ya producen posiciones deterministas. El algoritmo `graph` está reservado en el contrato y, por ahora, emite `GRAPH_LAYOUT_FALLBACK` y usa `grid`; las restricciones espaciales y la proyección de relaciones entre contenedores se incorporarán en iteraciones posteriores.
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.6",
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 and optional columns, rows and
76
- layers. gap and padding are absolute numbers. justify accepts start, center or end.
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 and
121
- shellLabels. theme contains background, lighting and ui settings.
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(...placements.slice(0, index).map(candidate =>
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 constrainedLayout(items, spec, source, diagnostics, ownerId, layout) {
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
- const result = layout(items, spec, diagnostics);
469
- applyAlignmentConstraints(result.positions, constraints, diagnostics, ownerId, spec);
470
- return constraints.some(constraint => constraint?.align)
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 rowLayout(items, spec, diagnostics) {
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 total = lengths.reduce((sum, value) => sum + value, 0) + Math.max(0, items.length - 1) * gap;
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 = -total / 2;
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 + gap;
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
- if (spec.justify === "end") for (const placement of placements) placement.position[axis] += total / 2;
513
- if (spec.justify === "start") for (const placement of placements) placement.position[axis] -= total / 2;
514
- if (spec.justify && !["start", "center", "end"].includes(spec.justify)) diagnostic(diagnostics, "warning", "UNKNOWN_JUSTIFY", `Unknown row justify '${spec.justify}'.`);
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 cellX = Math.max(...placements.map(item => item.size[0] / item.span[0]), 1);
590
- const cellZ = Math.max(...placements.map(item => item.size[2] / item.span[1]), 1);
591
- const cellY = Math.max(...placements.map(item => item.size[1]), 1);
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 width = item.span[0] * cellX + (item.span[0] - 1) * gap;
594
- const depth = item.span[1] * cellZ + (item.span[1] - 1) * gap;
595
- const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (cellX + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
596
- const slotSize = [width, cellY, depth];
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 = [item.cell[0] * (cellX + gap) + width / 2 + offset, 0, -(item.cell[1] * (cellZ + gap) + depth / 2)];
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("Container label visibility.", [true, false])
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
@@ -1,6 +0,0 @@
1
- {
2
- "name": "atlas-layout",
3
- "lockfileVersion": 3,
4
- "requires": true,
5
- "packages": {}
6
- }
@@ -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
- });