@liquidcars/atlas-layout 0.1.8 → 0.1.10

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
@@ -60,6 +60,13 @@ render:
60
60
  zoom: 1.35
61
61
  ```
62
62
 
63
+ ## Filtered projections
64
+
65
+ `compileAtlasProjection(source, visibleIds, options)` compiles a temporary model
66
+ containing only the requested entities and their required ancestors. Relations,
67
+ constraints and virtual layout groups are pruned consistently. The source object is
68
+ never mutated, making the helper suitable for selection-driven `render.focus` reflow.
69
+
63
70
  Las coordenadas son coordenadas del modelo. `zoom` es un multiplicador de
64
71
  encuadre independiente del tamaño del viewport y se conserva al compilar YAML
65
72
  o Markdown.
@@ -127,13 +134,15 @@ relations:
127
134
  from: source
128
135
  to: rules
129
136
  label: feeds
137
+ text: Sends normalized source data to the rules engine.
138
+ url: https://example.com/relations/source-to-rules
130
139
  mode: bidirectional
131
140
  - from: rules
132
141
  to: output
133
142
  mode: broken
134
143
  ```
135
144
 
136
- `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`.
145
+ `label` es el título corto visible junto a la conexión, `text` permite documentar su significado y `url` enlaza documentación externa desde el panel de información. `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`.
137
146
 
138
147
  En un documento Markdown dividido, el bloque de relaciones contiene una lista YAML:
139
148
 
@@ -187,4 +196,4 @@ El resultado mantiene `palette`, `theme`, `entities` y `relations`, con `p` y `s
187
196
 
188
197
  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.
189
198
 
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.
199
+ `row`, `grid`/`masonry` y `volume` producen posiciones deterministas. `variant: masonry` desplaza las filas alternas media celda sin exigir una sección `stagger`; `stagger.offset` queda disponible como override explícito. Para conservar legibilidad frontal se recomienda `plane: xy`, y para una composición lateral, `plane: yz`. Las constraints de tamaño y alineación, el empaquetado compacto, los grupos virtuales y la distribución `justify` forman parte del contrato estable de autoría. 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.8",
3
+ "version": "0.1.10",
4
4
  "description": "Declarative layout compiler for LiquidCars Atlas models",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -47,7 +47,7 @@ subject to sizeMode and diagnostics.
47
47
 
48
48
  Required: id.
49
49
 
50
- Common fields: name, sub, text, type, parent, children, geometry, geometryOptions,
50
+ Common fields: name, sub, text, url, type, parent, children, geometry, geometryOptions,
51
51
  geometryPalette, style, layout, weight, span, size, sizeMode, p, s, c and radius.
52
52
 
53
53
  An entity with children is a container and must explicitly declare `type: container`.
@@ -73,7 +73,10 @@ fit permits assisted layout to use the geometry's canonical size.
73
73
 
74
74
  layout.algorithm accepts row, grid, volume or graph. direction accepts x, y or z.
75
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.
76
+ a plane of xz (the default), xy or yz. Masonry offsets alternate rows by half a cell
77
+ unless `stagger.offset` explicitly overrides that distance. Use plane xy for layouts
78
+ intended to remain legible from the natural frontal camera, or yz for a lateral view.
79
+ Volume layouts additionally accept layers.
77
80
  gap and padding are absolute numbers. justify accepts start, center, end,
78
81
  space-between, space-around or space-evenly. These modes become distinct when a
79
82
  row receives an explicit or constrained size. Distributed modes treat gap as the
@@ -145,13 +148,21 @@ constraints:
145
148
 
146
149
  Relations require from and to entity ids. mode is directed, bidirectional or broken.
147
150
  The aliases visual, render, type and direction are accepted for compatibility.
148
- Optional fields include id, label, fromAnchor, toAnchor, priority and layout.
151
+ Optional fields include id, label, text, url, fromAnchor, toAnchor, priority and layout.
152
+ `label` is the compact caption associated with the connection; `text` is its longer
153
+ description and `url` links to external documentation when the relation is selected
154
+ in the information panel.
149
155
 
150
156
  ## Rendering
151
157
 
152
158
  palette maps tokens to CSS colours. style.color or c can reference a token or direct
153
159
  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
160
+ shellLabels (the text-label contrast outline), focus and camera. `render.focus`
161
+ accepts `mode: all|connected`, `effect: dim|hide`, `layout: preserve|reflow`
162
+ and an optional dim `opacity`. Connected focus retains the selection, its directly
163
+ related entities and the ancestor containers required to preserve context. Preserve
164
+ keeps spatial memory; reflow compiles a temporary projection and never mutates the
165
+ source model. An initial camera snapshot contains model-space
155
166
  position and target vectors plus a positive, dimensionless zoom value. Position
156
167
  and target determine the viewing direction; zoom preserves the intended framing
157
168
  across viewport sizes. theme contains background, lighting and ui settings.
package/src/index.js CHANGED
@@ -242,6 +242,8 @@ function normalizeRelations(input, validIds, diagnostics) {
242
242
  index
243
243
  };
244
244
  if (relation.id != null) normalized.id = String(relation.id);
245
+ if (relation.text != null) normalized.text = String(relation.text);
246
+ if (relation.url != null) normalized.url = String(relation.url);
245
247
  if (relation.fromAnchor != null || relation.from_anchor != null) normalized.fromAnchor = String(relation.fromAnchor ?? relation.from_anchor);
246
248
  if (relation.toAnchor != null || relation.to_anchor != null) normalized.toAnchor = String(relation.toAnchor ?? relation.to_anchor);
247
249
  return [normalized];
@@ -890,7 +892,12 @@ function packedGridLayout(items, spec, diagnostics, dimension = 2) {
890
892
  for (const item of placements) {
891
893
  const columnSize = item.span[0] * columnCell + (item.span[0] - 1) * gap;
892
894
  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;
895
+ const staggerOffset = spec.stagger?.offset;
896
+ const offset = spec.variant === "masonry" && item.cell[1] % 2 === 1
897
+ ? (staggerOffset == null || staggerOffset === "half-cell"
898
+ ? (columnCell + gap) / 2
899
+ : scalar(staggerOffset, 0))
900
+ : 0;
894
901
  const slotSize = [0, 0, 0];
895
902
  slotSize[columnAxis] = columnSize;
896
903
  slotSize[rowAxis] = rowSize;
@@ -964,6 +971,81 @@ function normalizeRoot(input) {
964
971
  return input || {};
965
972
  }
966
973
 
974
+ function projectedAtlasSource(input, visibleIds) {
975
+ const source = structuredClone(normalizeRoot(input));
976
+ const requested = new Set(Array.from(visibleIds || []).filter(Boolean));
977
+ const records = new Map();
978
+
979
+ function collect(items, nestedParent = null) {
980
+ for (const entity of Array.isArray(items) ? items : []) {
981
+ if (!entity?.id) continue;
982
+ const parent = nestedParent ?? entity.parent ?? null;
983
+ records.set(entity.id, { entity, parent });
984
+ collect(entity.children, entity.id);
985
+ }
986
+ }
987
+ collect(source.entities);
988
+ for (const id of [...requested]) {
989
+ let parent = records.get(id)?.parent || null;
990
+ while (parent && !requested.has(parent)) {
991
+ requested.add(parent);
992
+ parent = records.get(parent)?.parent || null;
993
+ }
994
+ }
995
+
996
+ function filterEntities(items) {
997
+ return (Array.isArray(items) ? items : []).flatMap(entity => {
998
+ if (!entity?.id || !requested.has(entity.id)) return [];
999
+ const copy = { ...entity };
1000
+ if (Array.isArray(entity.children)) copy.children = filterEntities(entity.children);
1001
+ return [copy];
1002
+ });
1003
+ }
1004
+ source.entities = filterEntities(source.entities);
1005
+ source.relations = (source.relations || []).filter(relation => requested.has(relation.from) && requested.has(relation.to));
1006
+
1007
+ const childrenByParent = new Map();
1008
+ function index(items, nestedParent = null) {
1009
+ for (const entity of items || []) {
1010
+ const parent = nestedParent ?? entity.parent ?? null;
1011
+ if (!childrenByParent.has(parent)) childrenByParent.set(parent, []);
1012
+ childrenByParent.get(parent).push(entity.id);
1013
+ index(entity.children, entity.id);
1014
+ }
1015
+ }
1016
+ index(source.entities);
1017
+
1018
+ function pruneOwner(owner, ownerId = null) {
1019
+ const siblingIds = new Set(childrenByParent.get(ownerId) || []);
1020
+ const layout = owner.layout ? { ...owner.layout } : null;
1021
+ if (layout?.groups) {
1022
+ layout.groups = layout.groups.flatMap(group => {
1023
+ const members = (group.members || []).filter(id => siblingIds.has(id));
1024
+ return members.length ? [{ ...group, members }] : [];
1025
+ });
1026
+ if (!layout.groups.length) delete layout.groups;
1027
+ owner.layout = layout;
1028
+ }
1029
+ const candidates = new Set([...siblingIds, ...(layout?.groups || []).map(group => group.id)]);
1030
+ if (Array.isArray(owner.constraints)) {
1031
+ owner.constraints = owner.constraints.flatMap(constraint => {
1032
+ const key = Array.isArray(constraint.align) ? "align" : Array.isArray(constraint.equal) ? "equal" : null;
1033
+ if (!key) return [constraint];
1034
+ const ids = constraint[key].filter(id => candidates.has(id));
1035
+ return ids.length >= 2 ? [{ ...constraint, [key]: ids }] : [];
1036
+ });
1037
+ if (!owner.constraints.length) delete owner.constraints;
1038
+ }
1039
+ for (const entity of ownerId === null ? source.entities : owner.children || []) pruneOwner(entity, entity.id);
1040
+ }
1041
+ pruneOwner(source, null);
1042
+ return source;
1043
+ }
1044
+
1045
+ export function compileAtlasProjection(input, visibleIds, options = {}) {
1046
+ return compileAtlasModel(projectedAtlasSource(input, visibleIds), options);
1047
+ }
1048
+
967
1049
  export function compileAtlasModel(input, options = {}) {
968
1050
  const diagnostics = [];
969
1051
  const source = normalizeRoot(input);
package/src/spec.js CHANGED
@@ -30,6 +30,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
30
30
  name: field("Visible title.", null, { type: "string" }),
31
31
  sub: field("Visible subtitle.", null, { type: "string" }),
32
32
  text: field("Description shown by the inspector.", null, { type: "string" }),
33
+ url: field("External documentation opened from the inspector.", null, { type: "url" }),
33
34
  type: field("Structural role. Use container when declaring children; omit it for a leaf item.", ["container"]),
34
35
  parent: field("Parent entity id.", null, { type: "string", authoring: "absolute" }),
35
36
  children: field("Nested entities.", null, { context: "entity", sequence: true }),
@@ -56,6 +57,8 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
56
57
  from: field("Source entity id.", null, { type: "string", required: true }),
57
58
  to: field("Target entity id.", null, { type: "string", required: true }),
58
59
  label: field("Text shown on the relation.", null, { type: "string" }),
60
+ text: field("Description shown by the inspector.", null, { type: "string" }),
61
+ url: field("External documentation opened from the inspector.", null, { type: "url" }),
59
62
  mode: field("Canonical visual relation mode.", ["directed", "bidirectional", "broken"]),
60
63
  visual: field("Historical alias for relation mode.", ["directed", "bidirectional", "broken"]),
61
64
  fromAnchor: field("Named source anchor.", null, { type: "string" }),
@@ -69,7 +72,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
69
72
  properties: freeze({
70
73
  algorithm: field("Spatial strategy.", ["row", "grid", "volume", "graph"]),
71
74
  direction: field("Primary semantic axis.", ["x", "y", "z"]),
72
- variant: field("Regular or staggered grid cells.", ["uniform", "masonry"]),
75
+ variant: field("Regular cells or masonry rows staggered by half a cell by default.", ["uniform", "masonry"]),
73
76
  plane: field("Axes used by a two-dimensional grid.", ["xz", "xy", "yz"]),
74
77
  packing: field("Preserve initial slots or compact free space after constraints.", ["preserve", "compact"]),
75
78
  groups: field("Invisible sibling groups used only during layout.", null, { context: "layoutGroup", sequence: true }),
@@ -79,7 +82,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
79
82
  gap: field("Spacing between children.", null, { type: "number" }),
80
83
  padding: field("Spacing to the container boundary.", null, { type: "number" }),
81
84
  justify: field("Row alignment or distribution within an assigned size.", ["start", "center", "end", "space-between", "space-around", "space-evenly"]),
82
- stagger: field("Optional masonry offset.", null, { context: "stagger" }),
85
+ stagger: field("Optional override for the masonry row offset.", null, { context: "stagger" }),
83
86
  constraints: field("Equal-size and edge-alignment rules for this layout's siblings.", null, { context: "constraint", sequence: true })
84
87
  })
85
88
  }),
@@ -137,9 +140,19 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
137
140
  selectionMode: field("Selection ownership.", ["internal", "event"]),
138
141
  toolbar: field("Built-in toolbar visibility.", [true, false]),
139
142
  shellLabels: field("Text-label contrast outline.", [true, false]),
143
+ focus: field("Selection-driven visual filtering.", null, { context: "focus" }),
140
144
  camera: field("Initial camera snapshot in model-space coordinates.", null, { context: "camera" })
141
145
  })
142
146
  }),
147
+ focus: freeze({
148
+ label: "Visual focus",
149
+ properties: freeze({
150
+ mode: field("Entity set retained by the visual filter.", ["all", "connected"]),
151
+ effect: field("Treatment of entities outside the focused set.", ["dim", "hide"]),
152
+ layout: field("Keep spatial memory or reflow the focused projection.", ["preserve", "reflow"]),
153
+ opacity: field("Opacity applied to dimmed entities.", null, { type: "number" })
154
+ })
155
+ }),
143
156
  camera: freeze({
144
157
  label: "Initial camera",
145
158
  properties: freeze({
@@ -159,7 +172,7 @@ export const ATLAS_LAYOUT_SPEC_V1 = freeze({
159
172
  stagger: freeze({
160
173
  label: "Stagger",
161
174
  properties: freeze({
162
- offset: field("Masonry row offset.", ["half-cell"], { type: "string" })
175
+ offset: field("Masonry row offset; defaults to half-cell.", ["half-cell"], { type: "string" })
163
176
  })
164
177
  }),
165
178
  label: freeze({ label: "Label appearance", properties: freeze({ color: field("Label colour.", null, { valueSource: "palette" }), opacity: field("Label opacity.", null, { type: "number" }) }) }),
@@ -186,6 +199,7 @@ export function atlasLayoutContextForPath(path = []) {
186
199
  ["palette", "palette"],
187
200
  ["theme", "theme"],
188
201
  ["render", "render"],
202
+ ["focus", "focus"],
189
203
  ["camera", "camera"],
190
204
  ["span", "span"],
191
205
  ["stagger", "stagger"],