@liquidcars/atlas-layout 0.1.3
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 +150 -0
- package/package.json +34 -0
- package/src/build.js +20 -0
- package/src/index.js +549 -0
- package/src/vite.js +62 -0
- package/src/yaml.js +142 -0
- package/test/index.test.mjs +326 -0
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# `@liquidcars/atlas-layout`
|
|
2
|
+
|
|
3
|
+
Compilador inicial para modelos declarativos de LiquidCars Atlas. Convierte entidades anidadas con layouts `row`, `grid`, `masonry` y `volume` en el modelo plano que consume `@liquidcars/atlas`.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { compileAtlasModel } from "@liquidcars/atlas-layout";
|
|
7
|
+
|
|
8
|
+
const resolved = compileAtlasModel(authorModel);
|
|
9
|
+
atlas.model = resolved;
|
|
10
|
+
|
|
11
|
+
console.table(resolved.diagnostics);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Las librerías de geometrías pueden aportar sus proporciones canónicas sin
|
|
15
|
+
acoplar el compilador al renderer:
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
const resolved = compileAtlasModel(authorModel, {
|
|
19
|
+
geometryCatalog: getGeometryCatalog()
|
|
20
|
+
});
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Cada entrada del catálogo puede declarar `id`, `aliases` y `canonicalSize`.
|
|
24
|
+
Una geometría externa desconocida conserva el tamaño de fallback de una caja,
|
|
25
|
+
de modo que la compilación sigue siendo recuperable si falta un pack.
|
|
26
|
+
|
|
27
|
+
Para compilar una fuente YAML durante el build:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { compileAtlasFileToJson } from "@liquidcars/atlas-layout/build";
|
|
31
|
+
|
|
32
|
+
await compileAtlasFileToJson(
|
|
33
|
+
"layers-model.atlas.yaml",
|
|
34
|
+
"public/layers-model.resolved.json"
|
|
35
|
+
);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
En Vite (y también en Astro mediante `vite.plugins`) se pueden importar las fuentes directamente:
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// vite.config.js
|
|
42
|
+
import atlasLayout from "@liquidcars/atlas-layout/vite";
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
plugins: [atlasLayout()]
|
|
46
|
+
};
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
// src/main.js
|
|
51
|
+
import "@liquidcars/atlas";
|
|
52
|
+
import atlasModel from "../layers-model.atlas.yaml";
|
|
53
|
+
|
|
54
|
+
document.querySelector("liquidcars-atlas").model = atlasModel;
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
El plugin transforma `.atlas.yaml`, `.atlas.yml`, `.atlas.md` y `.atlas.markdown` en módulos ESM durante el build y reenvía sus warnings al diagnóstico del bundler. Un diagnóstico `fatal` detiene el build.
|
|
58
|
+
|
|
59
|
+
También se puede compilar texto YAML directamente:
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { compileAtlasYaml } from "@liquidcars/atlas-layout/yaml";
|
|
63
|
+
|
|
64
|
+
const resolved = compileAtlasYaml(yamlText);
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
La API principal recibe un objeto JavaScript ya parseado. Se aceptan entidades anidadas mediante `children` o entidades planas con referencias `parent`; internamente ambas formas se normalizan al contrato plano. Las entradas de texto se manejan mediante los subpaths `/yaml` y `/build` durante el build.
|
|
68
|
+
|
|
69
|
+
En Markdown se acepta front matter YAML, un bloque completo ` ```atlas ` o bloques separados ` ```atlas-model `, ` ```atlas-layout `, ` ```atlas-render ` y ` ```atlas-relations `. Por ejemplo:
|
|
70
|
+
|
|
71
|
+
````markdown
|
|
72
|
+
```atlas
|
|
73
|
+
version: 1
|
|
74
|
+
entities:
|
|
75
|
+
- id: source
|
|
76
|
+
geometry: sphere
|
|
77
|
+
layout:
|
|
78
|
+
algorithm: row
|
|
79
|
+
```
|
|
80
|
+
````
|
|
81
|
+
|
|
82
|
+
Las relaciones admiten el modo visual canónico `directed`, `bidirectional` o `broken`:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
relations:
|
|
86
|
+
- id: source-to-rules
|
|
87
|
+
from: source
|
|
88
|
+
to: rules
|
|
89
|
+
label: feeds
|
|
90
|
+
mode: bidirectional
|
|
91
|
+
- from: rules
|
|
92
|
+
to: output
|
|
93
|
+
mode: broken
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`mode` es la forma recomendada y el compilador siempre la emite normalizada. Para facilitar la migración se aceptan también `visual`, `render`, `type`, `direction`, `bidirectional: true` y `broken: true`, además de los alias `two-way`, `both` e `interrupted`. La forma abreviada puede incluir el modo como cuarto valor: `[from, to, label, mode]`. Un modo desconocido conserva la relación como `directed` y añade el diagnóstico `UNKNOWN_RELATION_MODE`.
|
|
97
|
+
|
|
98
|
+
En un documento Markdown dividido, el bloque de relaciones contiene una lista YAML:
|
|
99
|
+
|
|
100
|
+
````markdown
|
|
101
|
+
```atlas-relations
|
|
102
|
+
- from: source
|
|
103
|
+
to: rules
|
|
104
|
+
mode: bidirectional
|
|
105
|
+
```
|
|
106
|
+
````
|
|
107
|
+
|
|
108
|
+
Un mismo documento puede contener varios modelos nombrados. El nombre se añade
|
|
109
|
+
al identificador del bloque, separado por espacio o por `:`:
|
|
110
|
+
|
|
111
|
+
````markdown
|
|
112
|
+
```atlas overview
|
|
113
|
+
entities:
|
|
114
|
+
- id: source
|
|
115
|
+
geometry: sphere
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
```atlas process
|
|
119
|
+
entities:
|
|
120
|
+
- id: rules
|
|
121
|
+
geometry: prism
|
|
122
|
+
```
|
|
123
|
+
````
|
|
124
|
+
|
|
125
|
+
Los bloques divididos usan el mismo nombre (`atlas-model overview`,
|
|
126
|
+
`atlas-relations overview`, etc.). Para estos documentos se utiliza la API
|
|
127
|
+
`compileAtlasMarkdownDocument`, que devuelve `{ models, defaultModel }`:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
import { compileAtlasMarkdownDocument } from "@liquidcars/atlas-layout/yaml";
|
|
131
|
+
|
|
132
|
+
const document = compileAtlasMarkdownDocument(markdown);
|
|
133
|
+
document.models.overview; // modelo normalizado listo para Atlas
|
|
134
|
+
document.models.process; // segundo modelo normalizado
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`parseAtlasMarkdown` y `compileAtlasMarkdown` mantienen el contrato anterior
|
|
138
|
+
para un único modelo y emiten un error explícito si reciben varios. El plugin
|
|
139
|
+
de Vite conserva ese comportamiento para un solo modelo; con varios bloques
|
|
140
|
+
exporta el documento nombrado completo. También se puede declarar un conjunto
|
|
141
|
+
de modelos en front matter mediante `models:` y seleccionar el predeterminado
|
|
142
|
+
con `defaultModel:`.
|
|
143
|
+
|
|
144
|
+
El parser Markdown/YAML sólo se ejecuta en build time. El bundle del componente visual no incorpora este parser.
|
|
145
|
+
|
|
146
|
+
El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s` resueltos para todas las entidades. `diagnostics` contiene warnings, errores recuperables y errores fatales; los contenedores fijos que no pueden contener su contenido conservan `layoutError: "overflow"` para que el renderer pueda señalarlos visualmente.
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
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.
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@liquidcars/atlas-layout",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Declarative layout compiler for LiquidCars Atlas models",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"module": "./src/index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./src/index.js",
|
|
11
|
+
"default": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./yaml": {
|
|
14
|
+
"import": "./src/yaml.js",
|
|
15
|
+
"default": "./src/yaml.js"
|
|
16
|
+
},
|
|
17
|
+
"./build": {
|
|
18
|
+
"import": "./src/build.js",
|
|
19
|
+
"default": "./src/build.js"
|
|
20
|
+
},
|
|
21
|
+
"./vite": {
|
|
22
|
+
"import": "./src/vite.js",
|
|
23
|
+
"default": "./src/vite.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"test": "node --test test/index.test.mjs"
|
|
28
|
+
},
|
|
29
|
+
"keywords": ["liquidcars", "atlas", "layout", "3d", "graph", "web-component"],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"yaml": "^2.8.1"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { extname } from "node:path";
|
|
3
|
+
import { compileAtlasYaml, compileAtlasMarkdown, compileAtlasMarkdownDocument } from "./yaml.js";
|
|
4
|
+
|
|
5
|
+
export async function compileAtlasFile(filePath, options = {}) {
|
|
6
|
+
const source = await readFile(filePath, "utf8");
|
|
7
|
+
const extension = extname(filePath).toLowerCase();
|
|
8
|
+
if (extension === ".yaml" || extension === ".yml") return compileAtlasYaml(source, { ...options, sourceName: filePath });
|
|
9
|
+
if (extension === ".md" || extension === ".markdown") {
|
|
10
|
+
const markdownOptions = { ...options, sourceName: filePath };
|
|
11
|
+
return options.document ? compileAtlasMarkdownDocument(source, markdownOptions) : compileAtlasMarkdown(source, markdownOptions);
|
|
12
|
+
}
|
|
13
|
+
throw new TypeError(`Unsupported Atlas source extension '${extension}'. Use .yaml, .yml, .md or .markdown.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function compileAtlasFileToJson(inputPath, outputPath, options = {}) {
|
|
17
|
+
const model = await compileAtlasFile(inputPath, options);
|
|
18
|
+
await writeFile(outputPath, `${JSON.stringify(model, null, 2)}\n`, "utf8");
|
|
19
|
+
return model;
|
|
20
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
const DEFAULT_COLOR = "#b9d8eb";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_SIZES = Object.freeze({
|
|
4
|
+
box: [2.5, 1.2, 2.2],
|
|
5
|
+
cylinder: [2.5, 1.2, 2.2],
|
|
6
|
+
sphere: [2.2, 2.2, 2.2],
|
|
7
|
+
cone: [2.5, 1.4, 2.2],
|
|
8
|
+
"frustum-cone": [2.5, 1.4, 2.5],
|
|
9
|
+
"frustum-pyramid": [3.4, 2.1, 3.4],
|
|
10
|
+
"frustum-triangle": [3.4, 2.1, 3.4],
|
|
11
|
+
prism: [2.5, 1.4, 2.5],
|
|
12
|
+
"pyramid-square": [3.4, 2.1, 3.4],
|
|
13
|
+
"pyramid-triangle": [3.4, 2.1, 3.4],
|
|
14
|
+
octahedron: [2.3, 2.3, 2.3],
|
|
15
|
+
dodecahedron: [2.3, 2.3, 2.3],
|
|
16
|
+
icosahedron: [2.3, 2.3, 2.3]
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ASPECT_FACTORS = Object.freeze({
|
|
20
|
+
compact: [1, 1, 1],
|
|
21
|
+
wide: [1.35, 0.86, 0.86],
|
|
22
|
+
tall: [0.86, 1.35, 0.86],
|
|
23
|
+
deep: [0.86, 0.86, 1.35],
|
|
24
|
+
flat: [1.08, 0.56, 1.08]
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const ALGORITHMS = new Set(["row", "grid", "volume", "graph"]);
|
|
28
|
+
|
|
29
|
+
// Relation modes are part of the declarative model contract. The renderer
|
|
30
|
+
// accepts a few historical aliases, but the build-time compiler always emits
|
|
31
|
+
// one of these canonical values so downstream consumers can rely on a stable
|
|
32
|
+
// shape.
|
|
33
|
+
export const RELATION_MODES = Object.freeze(["directed", "bidirectional", "broken"]);
|
|
34
|
+
|
|
35
|
+
const RELATION_MODE_ALIASES = new Map([
|
|
36
|
+
["directed", "directed"],
|
|
37
|
+
["one-way", "directed"],
|
|
38
|
+
["oneway", "directed"],
|
|
39
|
+
["unidirectional", "directed"],
|
|
40
|
+
["arrow", "directed"],
|
|
41
|
+
["single", "directed"],
|
|
42
|
+
["bidirectional", "bidirectional"],
|
|
43
|
+
["bi-directional", "bidirectional"],
|
|
44
|
+
["two-way", "bidirectional"],
|
|
45
|
+
["twoway", "bidirectional"],
|
|
46
|
+
["duplex", "bidirectional"],
|
|
47
|
+
["both", "bidirectional"],
|
|
48
|
+
["broken", "broken"],
|
|
49
|
+
["interrupted", "broken"],
|
|
50
|
+
["disconnected", "broken"],
|
|
51
|
+
["failed", "broken"],
|
|
52
|
+
["cut", "broken"]
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
function asVector(value, fallback = [0, 0, 0]) {
|
|
56
|
+
if (Array.isArray(value) && value.length === 3 && value.every(Number.isFinite)) return value.map(Number);
|
|
57
|
+
return [...fallback];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function positiveNumber(value, fallback) {
|
|
61
|
+
return Number.isFinite(Number(value)) && Number(value) > 0 ? Number(value) : fallback;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function scalar(value, fallback = 0) {
|
|
65
|
+
return Number.isFinite(Number(value)) ? Number(value) : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function multiply(a, b) {
|
|
69
|
+
return [a[0] * b[0], a[1] * b[1], a[2] * b[2]];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function add(a, b) {
|
|
73
|
+
return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeStyle(parent = {}, own = {}) {
|
|
77
|
+
return {
|
|
78
|
+
...parent,
|
|
79
|
+
...own,
|
|
80
|
+
label: { ...(parent.label || {}), ...(own.label || {}) },
|
|
81
|
+
edges: { ...(parent.edges || {}), ...(own.edges || {}) }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function diagnostic(diagnostics, level, code, message, extra = {}) {
|
|
86
|
+
diagnostics.push({ level, code, message, ...extra, recoverable: level !== "fatal" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function flattenEntities(input, diagnostics) {
|
|
90
|
+
const entities = [];
|
|
91
|
+
const childrenById = new Map();
|
|
92
|
+
const sourceById = new Map();
|
|
93
|
+
const seen = new Set();
|
|
94
|
+
|
|
95
|
+
function visit(items, parent = null) {
|
|
96
|
+
if (!Array.isArray(items)) return;
|
|
97
|
+
for (const raw of items) {
|
|
98
|
+
if (!raw || typeof raw !== "object" || !raw.id) {
|
|
99
|
+
diagnostic(diagnostics, "warning", "INVALID_ENTITY", "An entity without a valid id was ignored.");
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(raw.id)) {
|
|
103
|
+
diagnostic(diagnostics, "warning", "DUPLICATE_ENTITY_ID", `Duplicate entity id '${raw.id}' was ignored.`, { entityId: raw.id });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
seen.add(raw.id);
|
|
107
|
+
const children = Array.isArray(raw.children) ? raw.children : [];
|
|
108
|
+
const entity = { ...raw };
|
|
109
|
+
delete entity.children;
|
|
110
|
+
delete entity.layout;
|
|
111
|
+
entity.__atlasExplicitSize = Array.isArray(raw.size) && raw.size.length === 3;
|
|
112
|
+
entity.parent = parent ?? raw.parent ?? null;
|
|
113
|
+
entity.type = children.length || raw.type === "container" || raw.type === "shell" ? "shell" : raw.type;
|
|
114
|
+
entities.push(entity);
|
|
115
|
+
sourceById.set(entity.id, raw);
|
|
116
|
+
childrenById.set(entity.id, children.map(child => child && child.id).filter(Boolean));
|
|
117
|
+
visit(children, entity.id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
visit(input);
|
|
122
|
+
const ids = new Set(entities.map(entity => entity.id));
|
|
123
|
+
for (const entity of entities) {
|
|
124
|
+
if (entity.parent && !ids.has(entity.parent)) {
|
|
125
|
+
diagnostic(diagnostics, "warning", "UNKNOWN_PARENT", `Entity '${entity.id}' references missing parent '${entity.parent}' and was promoted to root.`, { entityId: entity.id, parentId: entity.parent });
|
|
126
|
+
entity.parent = null;
|
|
127
|
+
}
|
|
128
|
+
if (entity.parent && !childrenById.has(entity.parent)) childrenById.set(entity.parent, []);
|
|
129
|
+
if (entity.parent) {
|
|
130
|
+
const siblings = childrenById.get(entity.parent);
|
|
131
|
+
if (!siblings.includes(entity.id)) siblings.push(entity.id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return { entities, childrenById, sourceById };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalizeRelationMode(relation, diagnostics, index, shorthandMode) {
|
|
138
|
+
let candidate;
|
|
139
|
+
for (const value of [relation.mode, relation.visual, relation.render, relation.type, relation.direction, shorthandMode]) {
|
|
140
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
141
|
+
candidate = value;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (candidate == null && relation.bidirectional === true) candidate = "bidirectional";
|
|
146
|
+
if (candidate == null && relation.broken === true) candidate = "broken";
|
|
147
|
+
if (candidate == null || candidate === "") return "directed";
|
|
148
|
+
|
|
149
|
+
const key = String(candidate).trim().toLowerCase().replace(/[_ ]+/g, "-");
|
|
150
|
+
const mode = RELATION_MODE_ALIASES.get(key);
|
|
151
|
+
if (mode) return mode;
|
|
152
|
+
|
|
153
|
+
diagnostic(diagnostics, "warning", "UNKNOWN_RELATION_MODE", `Unknown relation mode '${candidate}' on relation ${index}; using directed.`, {
|
|
154
|
+
relationIndex: index,
|
|
155
|
+
mode: candidate,
|
|
156
|
+
fallback: "directed"
|
|
157
|
+
});
|
|
158
|
+
return "directed";
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function normalizeRelations(input, validIds, diagnostics) {
|
|
162
|
+
if (!Array.isArray(input)) return [];
|
|
163
|
+
return input.flatMap((raw, index) => {
|
|
164
|
+
const relation = Array.isArray(raw)
|
|
165
|
+
? { from: raw[0], to: raw[1], label: raw[2] || "", mode: raw[3] }
|
|
166
|
+
: { ...raw };
|
|
167
|
+
if (!validIds.has(relation.from) || !validIds.has(relation.to)) {
|
|
168
|
+
diagnostic(diagnostics, "warning", "UNKNOWN_RELATION_ENDPOINT", `Relation ${index} was ignored because one endpoint does not exist.`, { relationIndex: index, relation });
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
const normalized = {
|
|
172
|
+
from: relation.from,
|
|
173
|
+
to: relation.to,
|
|
174
|
+
label: relation.label || "",
|
|
175
|
+
mode: normalizeRelationMode(relation, diagnostics, index, Array.isArray(raw) ? raw[3] : undefined),
|
|
176
|
+
layout: relation.layout !== false,
|
|
177
|
+
priority: positiveNumber(relation.priority, 1),
|
|
178
|
+
index
|
|
179
|
+
};
|
|
180
|
+
if (relation.id != null) normalized.id = String(relation.id);
|
|
181
|
+
if (relation.fromAnchor != null || relation.from_anchor != null) normalized.fromAnchor = String(relation.fromAnchor ?? relation.from_anchor);
|
|
182
|
+
if (relation.toAnchor != null || relation.to_anchor != null) normalized.toAnchor = String(relation.toAnchor ?? relation.to_anchor);
|
|
183
|
+
return [normalized];
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function paletteFrom(source) {
|
|
188
|
+
return {
|
|
189
|
+
...(source.palette || {}),
|
|
190
|
+
...((source.render && source.render.palette) || {}),
|
|
191
|
+
default_color: (source.render && source.render.palette && source.render.palette.default_color) || source.palette?.default_color || DEFAULT_COLOR
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function resolveColor(value, inherited, palette, diagnostics, entityId) {
|
|
196
|
+
const candidate = value ?? inherited ?? "default_color";
|
|
197
|
+
if (typeof candidate !== "string") return palette.default_color;
|
|
198
|
+
if (palette[candidate]) return candidate;
|
|
199
|
+
if (/^(#|rgb|hsl|oklch|color\()/i.test(candidate)) return candidate;
|
|
200
|
+
diagnostic(diagnostics, "warning", "UNKNOWN_COLOR_TOKEN", `Unknown color token '${candidate}' on '${entityId}'.`, { entityId, token: candidate, fallback: "default_color" });
|
|
201
|
+
return palette.default_color;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolveStyles(entities, sourceById, childrenById, palette, diagnostics) {
|
|
205
|
+
const byId = new Map(entities.map(entity => [entity.id, entity]));
|
|
206
|
+
const resolved = new Map();
|
|
207
|
+
|
|
208
|
+
function visit(id, parentStyle = {}) {
|
|
209
|
+
const entity = byId.get(id);
|
|
210
|
+
if (!entity) return;
|
|
211
|
+
const source = sourceById.get(id) || {};
|
|
212
|
+
const style = mergeStyle(parentStyle, source.style || {});
|
|
213
|
+
style.color = resolveColor(style.color, parentStyle.color, palette, diagnostics, id);
|
|
214
|
+
entity.style = style;
|
|
215
|
+
entity.c = style.color;
|
|
216
|
+
if (style.radius != null && entity.radius == null) entity.radius = scalar(style.radius, 0);
|
|
217
|
+
resolved.set(id, style);
|
|
218
|
+
for (const childId of childrenById.get(id) || []) visit(childId, style);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
for (const entity of entities) if (!entity.parent) visit(entity.id);
|
|
222
|
+
return resolved;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function intrinsicSize(entity, algorithm = "grid", geometrySizes = DEFAULT_SIZES) {
|
|
226
|
+
if (Array.isArray(entity.size) && entity.size.length === 3) return asVector(entity.size, [1, 1, 1]);
|
|
227
|
+
const base = geometrySizes[entity.geometry] || DEFAULT_SIZES[entity.geometry] || DEFAULT_SIZES.box;
|
|
228
|
+
const aspectName = entity.style?.aspect || "compact";
|
|
229
|
+
const aspect = ASPECT_FACTORS[aspectName] || ASPECT_FACTORS.compact;
|
|
230
|
+
const weight = positiveNumber(entity.weight, 1);
|
|
231
|
+
const volumeScale = algorithm === "row" ? Math.cbrt(weight) : 1;
|
|
232
|
+
return multiply(base, aspect).map(value => value * volumeScale);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function sizeForSlot(item, slotSize) {
|
|
236
|
+
const base = item.size;
|
|
237
|
+
if (item.entity.type === "shell" || item.entity.__atlasExplicitSize) return [...base];
|
|
238
|
+
const scale = Math.min(
|
|
239
|
+
slotSize[0] / Math.max(base[0], 0.0001),
|
|
240
|
+
slotSize[1] / Math.max(base[1], 0.0001),
|
|
241
|
+
slotSize[2] / Math.max(base[2], 0.0001)
|
|
242
|
+
);
|
|
243
|
+
return base.map(value => value * Math.max(0, scale));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function spanFor(entity, dimension, minimumWeight) {
|
|
247
|
+
const explicit = entity.span || {};
|
|
248
|
+
if (dimension === 2 && (explicit.columns || explicit.rows)) {
|
|
249
|
+
return [Math.max(1, Math.round(scalar(explicit.columns, 1))), Math.max(1, Math.round(scalar(explicit.rows, 1)))];
|
|
250
|
+
}
|
|
251
|
+
if (dimension === 3 && (explicit.columns || explicit.rows || explicit.layers)) {
|
|
252
|
+
return [
|
|
253
|
+
Math.max(1, Math.round(scalar(explicit.columns, 1))),
|
|
254
|
+
Math.max(1, Math.round(scalar(explicit.rows, 1))),
|
|
255
|
+
Math.max(1, Math.round(scalar(explicit.layers, 1)))
|
|
256
|
+
];
|
|
257
|
+
}
|
|
258
|
+
const target = Math.max(1, Math.ceil(positiveNumber(entity.weight, 1) / minimumWeight));
|
|
259
|
+
if (dimension === 2) {
|
|
260
|
+
const columns = Math.max(1, Math.ceil(Math.sqrt(target)));
|
|
261
|
+
return [columns, Math.max(1, Math.ceil(target / columns))];
|
|
262
|
+
}
|
|
263
|
+
const columns = Math.max(1, Math.ceil(Math.cbrt(target)));
|
|
264
|
+
const rows = Math.max(1, Math.ceil(Math.sqrt(target / columns)));
|
|
265
|
+
return [columns, rows, Math.max(1, Math.ceil(target / (columns * rows)))];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function boundsOf(placements, padding) {
|
|
269
|
+
if (!placements.length) return { positions: [], size: [padding * 2, padding * 2, padding * 2] };
|
|
270
|
+
const min = [Infinity, Infinity, Infinity];
|
|
271
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
272
|
+
for (const item of placements) {
|
|
273
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
274
|
+
min[axis] = Math.min(min[axis], item.position[axis] - item.size[axis] / 2);
|
|
275
|
+
max[axis] = Math.max(max[axis], item.position[axis] + item.size[axis] / 2);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const center = min.map((value, axis) => (value + max[axis]) / 2);
|
|
279
|
+
const positions = placements.map(item => ({ ...item, position: item.position.map((value, axis) => {
|
|
280
|
+
const centered = value - center[axis];
|
|
281
|
+
return Object.is(centered, -0) ? 0 : centered;
|
|
282
|
+
}) }));
|
|
283
|
+
return { positions, size: max.map((value, axis) => value - min[axis] + padding * 2) };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function rowLayout(items, spec, diagnostics) {
|
|
287
|
+
const direction = ["x", "y", "z"].includes(spec.direction) ? spec.direction : "x";
|
|
288
|
+
const axis = { x: 0, y: 1, z: 2 }[direction];
|
|
289
|
+
const gap = Math.max(0, scalar(spec.gap, 1));
|
|
290
|
+
const padding = Math.max(0, scalar(spec.padding, 1));
|
|
291
|
+
const minimumWeight = Math.min(...items.map(item => positiveNumber(item.entity.weight, 1)));
|
|
292
|
+
const lengths = items.map(item => item.size[axis] * Math.max(1, positiveNumber(item.entity.weight, 1) / minimumWeight));
|
|
293
|
+
const total = lengths.reduce((sum, value) => sum + value, 0) + Math.max(0, items.length - 1) * gap;
|
|
294
|
+
const orderSign = axis === 0 ? 1 : -1;
|
|
295
|
+
let cursor = -total / 2;
|
|
296
|
+
const placements = items.map((item, index) => {
|
|
297
|
+
const length = lengths[index];
|
|
298
|
+
cursor += length / 2;
|
|
299
|
+
const position = [0, 0, 0];
|
|
300
|
+
position[axis] = cursor * orderSign;
|
|
301
|
+
cursor += length / 2 + gap;
|
|
302
|
+
const slotSize = item.size.map((value, itemAxis) => itemAxis === axis ? length : value);
|
|
303
|
+
return { ...item, size: sizeForSlot(item, slotSize), slotSize, position };
|
|
304
|
+
});
|
|
305
|
+
if (spec.justify === "end") for (const placement of placements) placement.position[axis] += total / 2;
|
|
306
|
+
if (spec.justify === "start") for (const placement of placements) placement.position[axis] -= total / 2;
|
|
307
|
+
if (spec.justify && !["start", "center", "end"].includes(spec.justify)) diagnostic(diagnostics, "warning", "UNKNOWN_JUSTIFY", `Unknown row justify '${spec.justify}'.`);
|
|
308
|
+
return boundsOf(placements, padding);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function gridDimensions(items, spec, dimension) {
|
|
312
|
+
const weights = items.map(item => positiveNumber(item.entity.weight, 1));
|
|
313
|
+
const minimumWeight = Math.min(...weights);
|
|
314
|
+
const spans = items.map(item => spanFor(item.entity, dimension, minimumWeight));
|
|
315
|
+
const weightedCells = spans.reduce((sum, span) => sum + span.reduce((product, value) => product * value, 1), 0);
|
|
316
|
+
if (dimension === 2) {
|
|
317
|
+
const columns = Math.max(1, Math.round(scalar(spec.columns, Math.ceil(Math.sqrt(weightedCells)))));
|
|
318
|
+
return { spans, minimumWeight, columns, rows: Math.max(1, Math.ceil(weightedCells / columns)) };
|
|
319
|
+
}
|
|
320
|
+
const columns = Math.max(1, Math.round(scalar(spec.columns, Math.ceil(Math.cbrt(weightedCells)))));
|
|
321
|
+
const rows = Math.max(1, Math.round(scalar(spec.rows, Math.ceil(Math.sqrt(weightedCells / columns)))));
|
|
322
|
+
return { spans, minimumWeight, columns, rows, layers: Math.max(1, Math.round(scalar(spec.layers, Math.ceil(weightedCells / (columns * rows))))) };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function occupiedKey(coords) {
|
|
326
|
+
return coords.join(":");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function canOccupy(occupied, start, span, limits) {
|
|
330
|
+
for (let z = 0; z < span[1]; z++) for (let x = 0; x < span[0]; x++) {
|
|
331
|
+
const column = start[0] + x;
|
|
332
|
+
const row = start[1] + z;
|
|
333
|
+
if (column >= limits.columns || row >= limits.rows || occupied.has(occupiedKey([column, row]))) return false;
|
|
334
|
+
}
|
|
335
|
+
return true;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function canOccupyVolume(occupied, start, span, limits) {
|
|
339
|
+
for (let y = 0; y < span[2]; y++) for (let z = 0; z < span[1]; z++) for (let x = 0; x < span[0]; x++) {
|
|
340
|
+
const coords = [start[0] + x, start[1] + z, start[2] + y];
|
|
341
|
+
if (coords[0] >= limits.columns || coords[1] >= limits.rows || coords[2] >= limits.layers || occupied.has(occupiedKey(coords))) return false;
|
|
342
|
+
}
|
|
343
|
+
return true;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function markOccupied(occupied, start, span, dimension) {
|
|
347
|
+
if (dimension === 2) {
|
|
348
|
+
for (let z = 0; z < span[1]; z++) for (let x = 0; x < span[0]; x++) occupied.add(occupiedKey([start[0] + x, start[1] + z]));
|
|
349
|
+
} else {
|
|
350
|
+
for (let y = 0; y < span[2]; y++) for (let z = 0; z < span[1]; z++) for (let x = 0; x < span[0]; x++) occupied.add(occupiedKey([start[0] + x, start[1] + z, start[2] + y]));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function packedGridLayout(items, spec, diagnostics, dimension = 2) {
|
|
355
|
+
const gap = Math.max(0, scalar(spec.gap, 1));
|
|
356
|
+
const padding = Math.max(0, scalar(spec.padding, 1));
|
|
357
|
+
const dims = gridDimensions(items, spec, dimension);
|
|
358
|
+
const limits = { columns: dims.columns, rows: dims.rows, layers: dims.layers || 1 };
|
|
359
|
+
const occupied = new Set();
|
|
360
|
+
const placements = [];
|
|
361
|
+
const base = dimension === 2 ? [0, 0] : [0, 0, 0];
|
|
362
|
+
|
|
363
|
+
if (dimension === 2) {
|
|
364
|
+
for (const item of items) {
|
|
365
|
+
const span = dims.spans[placements.length];
|
|
366
|
+
if (span[0] > limits.columns) {
|
|
367
|
+
diagnostic(diagnostics, "warning", "SPAN_EXCEEDS_COLUMNS", `Span for '${item.entity.id}' exceeds the declared columns.`, { entityId: item.entity.id });
|
|
368
|
+
limits.columns = span[0];
|
|
369
|
+
}
|
|
370
|
+
let start = null;
|
|
371
|
+
for (let row = 0; !start && row < limits.rows + items.length + 1; row++) for (let column = 0; column < limits.columns; column++) if (canOccupy(occupied, [column, row], span, limits)) {
|
|
372
|
+
start = [column, row];
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
if (!start) {
|
|
376
|
+
start = [0, limits.rows];
|
|
377
|
+
limits.rows += span[1];
|
|
378
|
+
}
|
|
379
|
+
markOccupied(occupied, start, span, 2);
|
|
380
|
+
placements.push({ ...item, span, cell: start });
|
|
381
|
+
}
|
|
382
|
+
const cellX = Math.max(...placements.map(item => item.size[0] / item.span[0]), 1);
|
|
383
|
+
const cellZ = Math.max(...placements.map(item => item.size[2] / item.span[1]), 1);
|
|
384
|
+
const cellY = Math.max(...placements.map(item => item.size[1]), 1);
|
|
385
|
+
for (const item of placements) {
|
|
386
|
+
const width = item.span[0] * cellX + (item.span[0] - 1) * gap;
|
|
387
|
+
const depth = item.span[1] * cellZ + (item.span[1] - 1) * gap;
|
|
388
|
+
const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1 ? (spec.stagger?.offset === "half-cell" ? (cellX + gap) / 2 : scalar(spec.stagger?.offset, 0)) : 0;
|
|
389
|
+
const slotSize = [width, cellY, depth];
|
|
390
|
+
item.size = sizeForSlot(item, slotSize);
|
|
391
|
+
item.slotSize = slotSize;
|
|
392
|
+
item.position = [item.cell[0] * (cellX + gap) + width / 2 + offset, 0, -(item.cell[1] * (cellZ + gap) + depth / 2)];
|
|
393
|
+
}
|
|
394
|
+
return boundsOf(placements, padding);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
for (const item of items) {
|
|
398
|
+
const span = dims.spans[placements.length];
|
|
399
|
+
if (span[0] > limits.columns) limits.columns = span[0];
|
|
400
|
+
if (span[1] > limits.rows) limits.rows = span[1];
|
|
401
|
+
if (span[2] > limits.layers) limits.layers = span[2];
|
|
402
|
+
let start = null;
|
|
403
|
+
for (let layer = 0; !start && layer < limits.layers + items.length + 1; layer++) for (let row = 0; !start && row < limits.rows; row++) for (let column = 0; column < limits.columns; column++) if (canOccupyVolume(occupied, [column, row, layer], span, limits)) {
|
|
404
|
+
start = [column, row, layer];
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
if (!start) {
|
|
408
|
+
start = [0, 0, limits.layers];
|
|
409
|
+
limits.layers += span[2];
|
|
410
|
+
}
|
|
411
|
+
markOccupied(occupied, start, span, 3);
|
|
412
|
+
placements.push({ ...item, span, cell: start });
|
|
413
|
+
}
|
|
414
|
+
const cellX = Math.max(...placements.map(item => item.size[0] / item.span[0]), 1);
|
|
415
|
+
const cellY = Math.max(...placements.map(item => item.size[1] / item.span[2]), 1);
|
|
416
|
+
const cellZ = Math.max(...placements.map(item => item.size[2] / item.span[1]), 1);
|
|
417
|
+
for (const item of placements) {
|
|
418
|
+
const width = item.span[0] * cellX + (item.span[0] - 1) * gap;
|
|
419
|
+
const height = item.span[2] * cellY + (item.span[2] - 1) * gap;
|
|
420
|
+
const depth = item.span[1] * cellZ + (item.span[1] - 1) * gap;
|
|
421
|
+
const slotSize = [width, height, depth];
|
|
422
|
+
item.size = sizeForSlot(item, slotSize);
|
|
423
|
+
item.slotSize = slotSize;
|
|
424
|
+
item.position = [
|
|
425
|
+
item.cell[0] * (cellX + gap) + width / 2,
|
|
426
|
+
item.cell[2] * (cellY + gap) + height / 2,
|
|
427
|
+
-(item.cell[1] * (cellZ + gap) + depth / 2)
|
|
428
|
+
];
|
|
429
|
+
}
|
|
430
|
+
return boundsOf(placements, padding);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function gridLayout(items, spec, diagnostics) {
|
|
434
|
+
const hasSpans = items.some(item => item.entity.span || positiveNumber(item.entity.weight, 1) > 1);
|
|
435
|
+
return hasSpans || spec.variant === "masonry" ? packedGridLayout(items, spec, diagnostics, 2) : packedGridLayout(items, { ...spec, columns: spec.columns || Math.ceil(Math.sqrt(items.length)) }, diagnostics, 2);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function volumeLayout(items, spec, diagnostics) {
|
|
439
|
+
return packedGridLayout(items, spec, diagnostics, 3);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function resolveTheme(source) {
|
|
443
|
+
const theme = source.theme || source.styles || {};
|
|
444
|
+
const render = source.render || {};
|
|
445
|
+
return {
|
|
446
|
+
...theme,
|
|
447
|
+
...(render.theme || {}),
|
|
448
|
+
ui: { ...(theme.ui || {}), ...(render.ui || {}) }
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function normalizeRoot(input) {
|
|
453
|
+
if (input && input.model && !input.entities && !input.relations) return { ...input.model, layout: input.layout, render: input.render };
|
|
454
|
+
return input || {};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function compileAtlasModel(input, options = {}) {
|
|
458
|
+
const diagnostics = [];
|
|
459
|
+
const source = normalizeRoot(input);
|
|
460
|
+
const palette = paletteFrom(source);
|
|
461
|
+
const flat = flattenEntities(source.entities, diagnostics);
|
|
462
|
+
if (!flat.entities.length) diagnostic(diagnostics, "fatal", "NO_VALID_ENTITIES", "The model does not contain any valid entities.");
|
|
463
|
+
const validIds = new Set(flat.entities.map(entity => entity.id));
|
|
464
|
+
const relations = normalizeRelations(source.relations, validIds, diagnostics);
|
|
465
|
+
resolveStyles(flat.entities, flat.sourceById, flat.childrenById, palette, diagnostics);
|
|
466
|
+
const byId = new Map(flat.entities.map(entity => [entity.id, entity]));
|
|
467
|
+
const geometrySizes = { ...DEFAULT_SIZES };
|
|
468
|
+
for (const definition of options.geometryCatalog || []) {
|
|
469
|
+
if (!definition?.id || !Array.isArray(definition.canonicalSize) || definition.canonicalSize.length !== 3) continue;
|
|
470
|
+
geometrySizes[definition.id] = asVector(definition.canonicalSize, DEFAULT_SIZES.box);
|
|
471
|
+
for (const alias of definition.aliases || []) geometrySizes[alias] = geometrySizes[definition.id];
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function layoutEntity(entityId) {
|
|
475
|
+
const entity = byId.get(entityId);
|
|
476
|
+
if (!entity) return [1, 1, 1];
|
|
477
|
+
const childIds = flat.childrenById.get(entityId) || [];
|
|
478
|
+
if (!childIds.length) {
|
|
479
|
+
entity.s = intrinsicSize(entity, "grid", geometrySizes);
|
|
480
|
+
entity.p = asVector(entity.p);
|
|
481
|
+
return entity.s;
|
|
482
|
+
}
|
|
483
|
+
const childItems = childIds.map(childId => {
|
|
484
|
+
const child = byId.get(childId);
|
|
485
|
+
return { entity: child, size: layoutEntity(childId) };
|
|
486
|
+
});
|
|
487
|
+
const sourceEntity = flat.sourceById.get(entityId) || {};
|
|
488
|
+
const spec = sourceEntity.layout || { algorithm: "grid" };
|
|
489
|
+
const algorithm = ALGORITHMS.has(spec.algorithm) ? spec.algorithm : "grid";
|
|
490
|
+
if (!ALGORITHMS.has(spec.algorithm || "grid")) diagnostic(diagnostics, "warning", "UNKNOWN_LAYOUT_ALGORITHM", `Unknown layout algorithm '${spec.algorithm}', using grid.`, { entityId });
|
|
491
|
+
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 });
|
|
492
|
+
const result = algorithm === "row"
|
|
493
|
+
? rowLayout(childItems, spec, diagnostics)
|
|
494
|
+
: algorithm === "volume"
|
|
495
|
+
? volumeLayout(childItems, spec, diagnostics)
|
|
496
|
+
: gridLayout(childItems, spec, diagnostics);
|
|
497
|
+
for (const placement of result.positions) {
|
|
498
|
+
const child = byId.get(placement.entity.id);
|
|
499
|
+
child.p = placement.position;
|
|
500
|
+
child.s = placement.size;
|
|
501
|
+
}
|
|
502
|
+
const declared = Array.isArray(entity.size) && entity.size.length === 3 ? asVector(entity.size) : null;
|
|
503
|
+
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;
|
|
504
|
+
if (declared && sourceEntity.sizeMode === "fixed" && result.size.some((value, axis) => value > declared[axis])) {
|
|
505
|
+
diagnostic(diagnostics, "error", "FIXED_LAYOUT_OVERFLOW", `Content exceeds fixed container '${entity.id}'.`, { entityId: entity.id, declaredSize: declared, requiredSize: result.size });
|
|
506
|
+
entity.layoutError = "overflow";
|
|
507
|
+
}
|
|
508
|
+
entity.p = asVector(entity.p);
|
|
509
|
+
return entity.s;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
const rootItems = flat.entities.filter(entity => !entity.parent).map(entity => ({ entity, size: layoutEntity(entity.id) }));
|
|
513
|
+
const rootSpec = source.layout || { algorithm: "row", direction: "y", gap: 1.5, padding: 1 };
|
|
514
|
+
const rootAlgorithm = ALGORITHMS.has(rootSpec.algorithm) ? rootSpec.algorithm : "row";
|
|
515
|
+
if (rootAlgorithm === "graph") diagnostic(diagnostics, "warning", "GRAPH_LAYOUT_FALLBACK", "Graph layout for the root is reserved for a later compiler pass; using grid for now.");
|
|
516
|
+
const rootResult = rootAlgorithm === "row"
|
|
517
|
+
? rowLayout(rootItems, rootSpec, diagnostics)
|
|
518
|
+
: rootAlgorithm === "volume"
|
|
519
|
+
? volumeLayout(rootItems, rootSpec, diagnostics)
|
|
520
|
+
: gridLayout(rootItems, rootSpec, diagnostics);
|
|
521
|
+
for (const placement of rootResult.positions) {
|
|
522
|
+
const entity = byId.get(placement.entity.id);
|
|
523
|
+
entity.p = placement.position;
|
|
524
|
+
entity.s = placement.size;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const output = {
|
|
528
|
+
palette,
|
|
529
|
+
theme: resolveTheme(source),
|
|
530
|
+
entities: flat.entities.map(entity => {
|
|
531
|
+
const copy = { ...entity };
|
|
532
|
+
delete copy.style;
|
|
533
|
+
delete copy.__atlasExplicitSize;
|
|
534
|
+
return copy;
|
|
535
|
+
}),
|
|
536
|
+
relations,
|
|
537
|
+
layout: source.layout || undefined,
|
|
538
|
+
diagnostics
|
|
539
|
+
};
|
|
540
|
+
if (options.throwOnFatal && diagnostics.some(item => item.level === "fatal")) throw new Error(diagnostics.filter(item => item.level === "fatal").map(item => item.message).join("\n"));
|
|
541
|
+
return output;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function compileAtlasModelWithDiagnostics(input, options = {}) {
|
|
545
|
+
const model = compileAtlasModel(input, options);
|
|
546
|
+
return { model, diagnostics: model.diagnostics || [] };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export const layoutDefaults = Object.freeze({ defaultColor: DEFAULT_COLOR, sizes: DEFAULT_SIZES, aspectFactors: ASPECT_FACTORS });
|
package/src/vite.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { extname, resolve } from "node:path";
|
|
3
|
+
import { compileAtlasMarkdownDocument, compileAtlasYaml } from "./yaml.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_EXTENSIONS = Object.freeze([".atlas.yaml", ".atlas.yml", ".atlas.md", ".atlas.markdown"]);
|
|
6
|
+
|
|
7
|
+
function stripQuery(id) {
|
|
8
|
+
return id.split("?", 1)[0];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function isAtlasSource(id, extensions) {
|
|
12
|
+
const path = stripQuery(id).toLowerCase();
|
|
13
|
+
return extensions.some(extension => path.endsWith(extension));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function messageForDiagnostic(item) {
|
|
17
|
+
const location = item.entityId ? ` [${item.entityId}]` : "";
|
|
18
|
+
return `${item.code}${location}: ${item.message}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function atlasLayout(options = {}) {
|
|
22
|
+
const extensions = Array.isArray(options.extensions) && options.extensions.length
|
|
23
|
+
? options.extensions.map(extension => extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`)
|
|
24
|
+
: DEFAULT_EXTENSIONS;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
name: "liquidcars-atlas-layout",
|
|
28
|
+
enforce: "pre",
|
|
29
|
+
|
|
30
|
+
resolveId(source, importer) {
|
|
31
|
+
if (!isAtlasSource(source, extensions)) return null;
|
|
32
|
+
return source.startsWith("/") ? source : resolve(importer ? importer.replace(/[^/]+$/, "") : process.cwd(), source);
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
async load(id) {
|
|
36
|
+
const filePath = stripQuery(id);
|
|
37
|
+
if (!isAtlasSource(filePath, extensions)) return null;
|
|
38
|
+
const source = await readFile(filePath, "utf8");
|
|
39
|
+
const extension = extname(filePath).toLowerCase();
|
|
40
|
+
const model = extension === ".md" || extension === ".markdown"
|
|
41
|
+
? compileAtlasMarkdownDocument(source, { sourceName: filePath })
|
|
42
|
+
: compileAtlasYaml(source, { sourceName: filePath });
|
|
43
|
+
const output = extension === ".md" || extension === ".markdown"
|
|
44
|
+
? Object.keys(model.models).length === 1 ? model.models[model.defaultModel] : model
|
|
45
|
+
: model;
|
|
46
|
+
const diagnostics = extension === ".md" || extension === ".markdown"
|
|
47
|
+
? Object.values(model.models).flatMap(value => value.diagnostics || [])
|
|
48
|
+
: model.diagnostics || [];
|
|
49
|
+
for (const item of diagnostics) {
|
|
50
|
+
const message = messageForDiagnostic(item);
|
|
51
|
+
if (item.level === "fatal") {
|
|
52
|
+
if (typeof this.error === "function") this.error(message);
|
|
53
|
+
throw new Error(message);
|
|
54
|
+
}
|
|
55
|
+
if (typeof this.warn === "function") this.warn(message);
|
|
56
|
+
}
|
|
57
|
+
return `export default ${JSON.stringify(output)};\nexport const diagnostics = ${JSON.stringify(diagnostics)};\n`;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { DEFAULT_EXTENSIONS };
|
package/src/yaml.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { parse } from "yaml";
|
|
2
|
+
import { compileAtlasModel } from "./index.js";
|
|
3
|
+
|
|
4
|
+
function parseValue(text, sourceName = "Atlas YAML") {
|
|
5
|
+
if (typeof text !== "string" || !text.trim()) throw new TypeError(`${sourceName} must contain YAML text.`);
|
|
6
|
+
const value = parse(text);
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function parseDocument(text, sourceName = "Atlas YAML") {
|
|
11
|
+
const value = parseValue(text, sourceName);
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
13
|
+
throw new SyntaxError(`${sourceName} must contain an Atlas object.`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseAtlasYaml(text, options = {}) {
|
|
19
|
+
return parseDocument(text, options.sourceName || "Atlas YAML");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function compileAtlasYaml(text, options = {}) {
|
|
23
|
+
return compileAtlasModel(parseAtlasYaml(text, options), options);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function markdownBlocks(markdown) {
|
|
27
|
+
const blocks = [];
|
|
28
|
+
const expression = /```([^\r\n`]*)\r?\n([\s\S]*?)```/gi;
|
|
29
|
+
for (const match of markdown.matchAll(expression)) {
|
|
30
|
+
const info = match[1].trim();
|
|
31
|
+
const tokens = info.split(/\s+/).filter(Boolean);
|
|
32
|
+
const opener = tokens.shift()?.toLowerCase() || "";
|
|
33
|
+
const inlineName = opener.match(/^(atlas(?:-yaml|-model|-layout|-render|-relations)?|yaml):(.+)$/i);
|
|
34
|
+
const kind = (inlineName ? inlineName[1] : opener).toLowerCase();
|
|
35
|
+
const name = (inlineName ? [inlineName[2], ...tokens] : tokens).join(" ").trim() || null;
|
|
36
|
+
if (["atlas", "atlas-yaml", "yaml", "atlas-model", "atlas-layout", "atlas-render", "atlas-relations"].includes(kind)) {
|
|
37
|
+
blocks.push({ kind, name, text: match[2] });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return blocks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function modelName(value) {
|
|
44
|
+
return typeof value === "string" && value.trim() ? value.trim() : "default";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function addModelGroup(groups, name) {
|
|
48
|
+
const id = modelName(name);
|
|
49
|
+
if (!groups.has(id)) groups.set(id, { full: null, model: null, layout: null, render: null, relations: null });
|
|
50
|
+
return groups.get(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function assignBlock(group, block, field, sourceName) {
|
|
54
|
+
if (group[field]) throw new SyntaxError(`${sourceName} contains more than one '${block.kind}' block for model '${modelName(block.name)}'.`);
|
|
55
|
+
group[field] = block;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parses a Markdown document into one or more named Atlas model sources.
|
|
60
|
+
*
|
|
61
|
+
* Named fenced blocks use `atlas <name>` (or `atlas:<name>`). Split blocks
|
|
62
|
+
* use the same suffix, for example `atlas-model overview` and
|
|
63
|
+
* `atlas-relations overview`. Unnamed documents retain the historical
|
|
64
|
+
* `default` model name.
|
|
65
|
+
*/
|
|
66
|
+
export function parseAtlasMarkdownDocument(markdown, options = {}) {
|
|
67
|
+
if (typeof markdown !== "string" || !markdown.trim()) throw new TypeError("Atlas Markdown must contain text.");
|
|
68
|
+
const frontmatter = markdown.match(/^---\s*\n([\s\S]*?)\n---(?:\s*\n|$)/);
|
|
69
|
+
if (frontmatter) {
|
|
70
|
+
const value = parseDocument(frontmatter[1], options.sourceName || "Atlas Markdown front matter");
|
|
71
|
+
if (value.models && typeof value.models === "object" && !Array.isArray(value.models)) {
|
|
72
|
+
const models = { ...value.models };
|
|
73
|
+
const names = Object.keys(models);
|
|
74
|
+
if (!names.length) throw new SyntaxError("Atlas Markdown front matter models must contain at least one named model.");
|
|
75
|
+
const defaultModel = modelName(value.defaultModel || options.defaultModel || names[0]);
|
|
76
|
+
if (!Object.hasOwn(models, defaultModel)) throw new SyntaxError(`Atlas Markdown default model '${defaultModel}' does not exist.`);
|
|
77
|
+
return { models, defaultModel };
|
|
78
|
+
}
|
|
79
|
+
return { models: { default: value }, defaultModel: "default" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const blocks = markdownBlocks(markdown);
|
|
83
|
+
if (!blocks.length) {
|
|
84
|
+
throw new SyntaxError("Atlas Markdown must contain front matter or an ```atlas``` YAML block.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const groups = new Map();
|
|
88
|
+
for (const block of blocks) {
|
|
89
|
+
const group = addModelGroup(groups, block.name);
|
|
90
|
+
if (["atlas", "atlas-yaml", "yaml"].includes(block.kind)) assignBlock(group, block, "full", "Atlas Markdown");
|
|
91
|
+
if (block.kind === "atlas-model") assignBlock(group, block, "model", "Atlas Markdown");
|
|
92
|
+
if (block.kind === "atlas-layout") assignBlock(group, block, "layout", "Atlas Markdown");
|
|
93
|
+
if (block.kind === "atlas-render") assignBlock(group, block, "render", "Atlas Markdown");
|
|
94
|
+
if (block.kind === "atlas-relations") assignBlock(group, block, "relations", "Atlas Markdown");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const models = {};
|
|
98
|
+
for (const [name, group] of groups) {
|
|
99
|
+
if (group.full && (group.model || group.layout || group.render || group.relations)) {
|
|
100
|
+
throw new SyntaxError(`Atlas Markdown model '${name}' mixes a full block with split model blocks.`);
|
|
101
|
+
}
|
|
102
|
+
if (group.full) {
|
|
103
|
+
models[name] = parseDocument(group.full.text, `Atlas Markdown model '${name}'`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const value = group.model ? parseDocument(group.model.text, `Atlas Markdown model block '${name}'`) : {};
|
|
107
|
+
if (group.layout) value.layout = parseDocument(group.layout.text, `Atlas Markdown layout block '${name}'`);
|
|
108
|
+
if (group.render) value.render = parseDocument(group.render.text, `Atlas Markdown render block '${name}'`);
|
|
109
|
+
if (group.relations) {
|
|
110
|
+
const parsed = parseValue(group.relations.text, `Atlas Markdown relations block '${name}'`);
|
|
111
|
+
const relationList = Array.isArray(parsed) ? parsed : parsed?.relations;
|
|
112
|
+
if (!Array.isArray(relationList)) throw new SyntaxError(`Atlas Markdown relations block '${name}' must contain a YAML list (or an object with relations).`);
|
|
113
|
+
value.relations = relationList;
|
|
114
|
+
}
|
|
115
|
+
models[name] = value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const names = Object.keys(models);
|
|
119
|
+
const defaultModel = modelName(options.defaultModel || (Object.hasOwn(models, "default") ? "default" : names[0]));
|
|
120
|
+
if (!Object.hasOwn(models, defaultModel)) throw new SyntaxError(`Atlas Markdown default model '${defaultModel}' does not exist.`);
|
|
121
|
+
return { models, defaultModel };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function parseAtlasMarkdown(markdown, options = {}) {
|
|
125
|
+
const document = parseAtlasMarkdownDocument(markdown, options);
|
|
126
|
+
const names = Object.keys(document.models);
|
|
127
|
+
if (names.length !== 1) throw new SyntaxError("Atlas Markdown contains multiple models; use parseAtlasMarkdownDocument() instead.");
|
|
128
|
+
return document.models[names[0]];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function compileAtlasMarkdown(markdown, options = {}) {
|
|
132
|
+
const document = compileAtlasMarkdownDocument(markdown, options);
|
|
133
|
+
const names = Object.keys(document.models);
|
|
134
|
+
if (names.length !== 1) throw new SyntaxError("Atlas Markdown contains multiple models; use compileAtlasMarkdownDocument() instead.");
|
|
135
|
+
return document.models[names[0]];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function compileAtlasMarkdownDocument(markdown, options = {}) {
|
|
139
|
+
const source = parseAtlasMarkdownDocument(markdown, options);
|
|
140
|
+
const models = Object.fromEntries(Object.entries(source.models).map(([name, value]) => [name, compileAtlasModel(value, options)]));
|
|
141
|
+
return { models, defaultModel: source.defaultModel };
|
|
142
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
test("normalizes nested entities and inherits colors", () => {
|
|
15
|
+
const model = compileAtlasModel({
|
|
16
|
+
entities: [{
|
|
17
|
+
id: "container",
|
|
18
|
+
style: { color: "top" },
|
|
19
|
+
layout: { algorithm: "grid", columns: 2 },
|
|
20
|
+
children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "sphere", style: { color: "warm" } }]
|
|
21
|
+
}],
|
|
22
|
+
render: { palette: { top: "#00f", warm: "#f80", default_color: "#ccc" } }
|
|
23
|
+
});
|
|
24
|
+
const byId = ids(model);
|
|
25
|
+
assert.equal(byId.get("a").parent, "container");
|
|
26
|
+
assert.equal(byId.get("a").c, "top");
|
|
27
|
+
assert.equal(byId.get("b").c, "warm");
|
|
28
|
+
assert.deepEqual(byId.get("container").p, [0, 0, 0]);
|
|
29
|
+
assert.equal(model.diagnostics.length, 0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("accepts flat parent references as well as nested children", () => {
|
|
33
|
+
const model = compileAtlasModel({
|
|
34
|
+
entities: [
|
|
35
|
+
{ id: "parent", style: { color: "top" }, layout: { algorithm: "row", direction: "x" } },
|
|
36
|
+
{ id: "child", parent: "parent", geometry: "box" }
|
|
37
|
+
],
|
|
38
|
+
render: { palette: { top: "#00f" } }
|
|
39
|
+
});
|
|
40
|
+
const byId = ids(model);
|
|
41
|
+
assert.equal(byId.get("child").parent, "parent");
|
|
42
|
+
assert.equal(byId.get("child").c, "top");
|
|
43
|
+
assert.equal(model.diagnostics.length, 0);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("row y places the first item above the next item", () => {
|
|
47
|
+
const model = compileAtlasModel({
|
|
48
|
+
layout: { algorithm: "row", direction: "y", gap: 1 },
|
|
49
|
+
entities: [{ id: "top", geometry: "box" }, { id: "bottom", geometry: "box" }]
|
|
50
|
+
});
|
|
51
|
+
const byId = ids(model);
|
|
52
|
+
assert.ok(byId.get("top").p[1] > byId.get("bottom").p[1]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("grid starts at the front and advances toward the back", () => {
|
|
56
|
+
const model = compileAtlasModel({
|
|
57
|
+
layout: { algorithm: "grid", columns: 1, gap: 1 },
|
|
58
|
+
entities: [{ id: "front", geometry: "box" }, { id: "back", geometry: "box" }]
|
|
59
|
+
});
|
|
60
|
+
const byId = ids(model);
|
|
61
|
+
assert.ok(byId.get("front").p[2] > byId.get("back").p[2]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("grid preserves canonical proportions inside non-cubic cells", () => {
|
|
65
|
+
const model = compileAtlasModel({
|
|
66
|
+
entities: [{ id: "sphere", geometry: "sphere" }, { id: "wide", geometry: "box", weight: 2 }],
|
|
67
|
+
layout: { algorithm: "grid", columns: 2, gap: 1 }
|
|
68
|
+
});
|
|
69
|
+
const sphere = ids(model).get("sphere");
|
|
70
|
+
assert.ok(Math.abs(sphere.s[0] - sphere.s[1]) < 1e-9);
|
|
71
|
+
assert.ok(Math.abs(sphere.s[1] - sphere.s[2]) < 1e-9);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("masonry offsets alternate rows", () => {
|
|
75
|
+
const model = compileAtlasModel({
|
|
76
|
+
layout: { algorithm: "grid", variant: "masonry", columns: 2, stagger: { mode: "alternate", offset: "half-cell" } },
|
|
77
|
+
entities: ["a", "b", "c", "d"].map(id => ({ id, geometry: "box" }))
|
|
78
|
+
});
|
|
79
|
+
const byId = ids(model);
|
|
80
|
+
assert.notEqual(byId.get("a").p[0], byId.get("c").p[0]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("volume distributes elements from bottom to top", () => {
|
|
84
|
+
const model = compileAtlasModel({
|
|
85
|
+
layout: { algorithm: "volume", columns: 1, rows: 1, layers: 2, gap: 1 },
|
|
86
|
+
entities: [{ id: "bottom", geometry: "box" }, { id: "top", geometry: "box" }]
|
|
87
|
+
});
|
|
88
|
+
const byId = ids(model);
|
|
89
|
+
assert.ok(byId.get("top").p[1] > byId.get("bottom").p[1]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("invalid relation endpoints produce recoverable diagnostics", () => {
|
|
93
|
+
const model = compileAtlasModel({
|
|
94
|
+
entities: [{ id: "a", geometry: "box" }],
|
|
95
|
+
relations: [{ from: "a", to: "missing", label: "broken" }]
|
|
96
|
+
});
|
|
97
|
+
assert.equal(model.relations.length, 0);
|
|
98
|
+
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_ENDPOINT");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("normalizes relation modes and keeps optional relation ids", () => {
|
|
102
|
+
const model = compileAtlasModel({
|
|
103
|
+
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }, { id: "c", geometry: "box" }, { id: "d", geometry: "box" }],
|
|
104
|
+
relations: [
|
|
105
|
+
{ id: "feed", from: "a", to: "b", mode: "bidirectional" },
|
|
106
|
+
{ from: "b", to: "c", visual: "interrupted" },
|
|
107
|
+
["c", "d", "legacy shorthand", "two-way"]
|
|
108
|
+
]
|
|
109
|
+
});
|
|
110
|
+
assert.deepEqual(model.relations.map(relation => relation.mode), ["bidirectional", "broken", "bidirectional"]);
|
|
111
|
+
assert.equal(model.relations[0].id, "feed");
|
|
112
|
+
assert.equal(model.diagnostics.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("invalid relation modes fall back to directed with a warning", () => {
|
|
116
|
+
const model = compileAtlasModel({
|
|
117
|
+
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }],
|
|
118
|
+
relations: [{ from: "a", to: "b", mode: "sideways" }]
|
|
119
|
+
});
|
|
120
|
+
assert.equal(model.relations[0].mode, "directed");
|
|
121
|
+
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_MODE");
|
|
122
|
+
assert.equal(model.diagnostics[0].fallback, "directed");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("fixed containers report overflow without hiding children", () => {
|
|
126
|
+
const model = compileAtlasModel({
|
|
127
|
+
entities: [{
|
|
128
|
+
id: "fixed",
|
|
129
|
+
size: [1, 1, 1],
|
|
130
|
+
sizeMode: "fixed",
|
|
131
|
+
layout: { algorithm: "row", gap: 1, padding: 1 },
|
|
132
|
+
children: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }]
|
|
133
|
+
}]
|
|
134
|
+
});
|
|
135
|
+
assert.equal(model.diagnostics[0].code, "FIXED_LAYOUT_OVERFLOW");
|
|
136
|
+
assert.equal(model.entities.length, 3);
|
|
137
|
+
assert.equal(ids(model).get("fixed").layoutError, "overflow");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("graph layout is reported as a recoverable fallback", () => {
|
|
141
|
+
const model = compileAtlasModel({
|
|
142
|
+
entities: [{ id: "a", geometry: "box" }, { id: "b", geometry: "box" }],
|
|
143
|
+
layout: { algorithm: "graph" },
|
|
144
|
+
relations: [{ from: "a", to: "b" }]
|
|
145
|
+
});
|
|
146
|
+
assert.equal(model.diagnostics[0].code, "GRAPH_LAYOUT_FALLBACK");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("compiles an Atlas YAML document at build time", () => {
|
|
150
|
+
const model = compileAtlasYaml(`
|
|
151
|
+
version: 1
|
|
152
|
+
render:
|
|
153
|
+
palette:
|
|
154
|
+
top: "#00f"
|
|
155
|
+
entities:
|
|
156
|
+
- id: root
|
|
157
|
+
layout:
|
|
158
|
+
algorithm: row
|
|
159
|
+
direction: x
|
|
160
|
+
children:
|
|
161
|
+
- id: child
|
|
162
|
+
geometry: sphere
|
|
163
|
+
style:
|
|
164
|
+
color: top
|
|
165
|
+
relations: []
|
|
166
|
+
`);
|
|
167
|
+
const child = ids(model).get("child");
|
|
168
|
+
assert.deepEqual(child.s, [2.2, 2.2, 2.2]);
|
|
169
|
+
assert.equal(child.c, "top");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("extracts a full Atlas block or split model/layout/render/relations blocks from Markdown", () => {
|
|
173
|
+
const full = parseAtlasMarkdown("# Demo\n\n```atlas\nentities:\n - id: a\n geometry: box\n```\n");
|
|
174
|
+
assert.equal(full.entities[0].id, "a");
|
|
175
|
+
|
|
176
|
+
const markdown = [
|
|
177
|
+
"# Layered model",
|
|
178
|
+
"",
|
|
179
|
+
"```atlas-model",
|
|
180
|
+
"entities:",
|
|
181
|
+
" - id: a",
|
|
182
|
+
" geometry: box",
|
|
183
|
+
"```",
|
|
184
|
+
"",
|
|
185
|
+
"```atlas-layout",
|
|
186
|
+
"algorithm: row",
|
|
187
|
+
"direction: x",
|
|
188
|
+
"```",
|
|
189
|
+
"",
|
|
190
|
+
"```atlas-render",
|
|
191
|
+
"palette:",
|
|
192
|
+
" default_color: \"#abc\"",
|
|
193
|
+
"```",
|
|
194
|
+
"",
|
|
195
|
+
"```atlas-relations",
|
|
196
|
+
"- from: a",
|
|
197
|
+
" to: missing",
|
|
198
|
+
" mode: broken",
|
|
199
|
+
"```",
|
|
200
|
+
""
|
|
201
|
+
].join("\n");
|
|
202
|
+
const model = compileAtlasMarkdown(markdown);
|
|
203
|
+
assert.equal(model.entities.length, 1);
|
|
204
|
+
assert.deepEqual(model.entities[0].p, [0, 0, 0]);
|
|
205
|
+
assert.equal(model.palette.default_color, "#abc");
|
|
206
|
+
assert.equal(model.relations.length, 0);
|
|
207
|
+
assert.equal(model.diagnostics[0].code, "UNKNOWN_RELATION_ENDPOINT");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("parses and compiles multiple named Atlas models in one Markdown document", () => {
|
|
211
|
+
const markdown = [
|
|
212
|
+
"# Two diagrams",
|
|
213
|
+
"",
|
|
214
|
+
"```atlas overview",
|
|
215
|
+
"entities:",
|
|
216
|
+
" - id: source",
|
|
217
|
+
" geometry: sphere",
|
|
218
|
+
"```",
|
|
219
|
+
"",
|
|
220
|
+
"Some explanatory text.",
|
|
221
|
+
"",
|
|
222
|
+
"```atlas process",
|
|
223
|
+
"entities:",
|
|
224
|
+
" - id: rules",
|
|
225
|
+
" geometry: prism",
|
|
226
|
+
"```",
|
|
227
|
+
""
|
|
228
|
+
].join("\n");
|
|
229
|
+
|
|
230
|
+
const parsed = parseAtlasMarkdownDocument(markdown);
|
|
231
|
+
assert.deepEqual(Object.keys(parsed.models), ["overview", "process"]);
|
|
232
|
+
assert.equal(parsed.defaultModel, "overview");
|
|
233
|
+
assert.equal(parsed.models.process.entities[0].id, "rules");
|
|
234
|
+
|
|
235
|
+
const compiled = compileAtlasMarkdownDocument(markdown);
|
|
236
|
+
assert.deepEqual(Object.keys(compiled.models), ["overview", "process"]);
|
|
237
|
+
assert.equal(compiled.models.overview.entities[0].s[0], 2.2);
|
|
238
|
+
assert.throws(() => parseAtlasMarkdown(markdown), /multiple models/);
|
|
239
|
+
assert.throws(() => compileAtlasMarkdown(markdown), /multiple models/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("associates named split Markdown blocks with the same model", () => {
|
|
243
|
+
const markdown = [
|
|
244
|
+
"```atlas-model overview",
|
|
245
|
+
"entities:",
|
|
246
|
+
" - id: a",
|
|
247
|
+
" geometry: box",
|
|
248
|
+
" - id: b",
|
|
249
|
+
" geometry: sphere",
|
|
250
|
+
"```",
|
|
251
|
+
"",
|
|
252
|
+
"```atlas-relations overview",
|
|
253
|
+
"- from: a",
|
|
254
|
+
" to: b",
|
|
255
|
+
" mode: bidirectional",
|
|
256
|
+
"```",
|
|
257
|
+
"",
|
|
258
|
+
"```atlas-model details",
|
|
259
|
+
"entities:",
|
|
260
|
+
" - id: c",
|
|
261
|
+
" geometry: cone",
|
|
262
|
+
"```",
|
|
263
|
+
""
|
|
264
|
+
].join("\n");
|
|
265
|
+
const document = compileAtlasMarkdownDocument(markdown, { defaultModel: "details" });
|
|
266
|
+
assert.equal(document.defaultModel, "details");
|
|
267
|
+
assert.equal(document.models.overview.relations[0].mode, "bidirectional");
|
|
268
|
+
assert.equal(document.models.details.entities[0].id, "c");
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("uses canonical sizes supplied by external geometry catalogs", () => {
|
|
272
|
+
const model = compileAtlasModel({
|
|
273
|
+
entities: [{ id: "server", geometry: "infra.server" }]
|
|
274
|
+
}, {
|
|
275
|
+
geometryCatalog: [{ id: "infra.server", aliases: ["server-rack"], canonicalSize: [3, 2, 4] }]
|
|
276
|
+
});
|
|
277
|
+
assert.deepEqual(model.entities[0].s, [3, 2, 4]);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("preserves named geometry anchors on relations", () => {
|
|
281
|
+
const model = compileAtlasModel({
|
|
282
|
+
entities: [{ id: "a" }, { id: "b" }],
|
|
283
|
+
relations: [{ from: "a", to: "b", fromAnchor: "network", to_anchor: "input" }]
|
|
284
|
+
});
|
|
285
|
+
assert.equal(model.relations[0].fromAnchor, "network");
|
|
286
|
+
assert.equal(model.relations[0].toAnchor, "input");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("Vite plugin turns an Atlas YAML import into a JS module", async () => {
|
|
290
|
+
const directory = await mkdtemp(join(tmpdir(), "atlas-layout-vite-"));
|
|
291
|
+
const sourcePath = join(directory, "model.atlas.yaml");
|
|
292
|
+
await writeFile(sourcePath, "entities:\n - id: source\n geometry: sphere\n", "utf8");
|
|
293
|
+
const plugin = atlasLayout();
|
|
294
|
+
const resolved = plugin.resolveId("./model.atlas.yaml", join(directory, "main.js"));
|
|
295
|
+
const warnings = [];
|
|
296
|
+
const code = await plugin.load.call({ warn: message => warnings.push(message) }, resolved);
|
|
297
|
+
assert.match(code, /export default/);
|
|
298
|
+
assert.match(code, /source/);
|
|
299
|
+
assert.equal(warnings.length, 0);
|
|
300
|
+
assert.equal((await readFile(sourcePath, "utf8")).includes("entities:"), true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("Vite plugin exposes multiple named Markdown models as a document", async () => {
|
|
304
|
+
const directory = await mkdtemp(join(tmpdir(), "atlas-layout-vite-multi-"));
|
|
305
|
+
const sourcePath = join(directory, "models.atlas.md");
|
|
306
|
+
await writeFile(sourcePath, [
|
|
307
|
+
"```atlas first",
|
|
308
|
+
"entities:",
|
|
309
|
+
" - id: first",
|
|
310
|
+
" geometry: box",
|
|
311
|
+
"```",
|
|
312
|
+
"",
|
|
313
|
+
"```atlas second",
|
|
314
|
+
"entities:",
|
|
315
|
+
" - id: second",
|
|
316
|
+
" geometry: sphere",
|
|
317
|
+
"```",
|
|
318
|
+
""
|
|
319
|
+
].join("\n"), "utf8");
|
|
320
|
+
const plugin = atlasLayout();
|
|
321
|
+
const resolved = plugin.resolveId("./models.atlas.md", join(directory, "main.js"));
|
|
322
|
+
const code = await plugin.load.call({ warn() {} }, resolved);
|
|
323
|
+
assert.match(code, /models/);
|
|
324
|
+
assert.match(code, /first/);
|
|
325
|
+
assert.match(code, /second/);
|
|
326
|
+
});
|