ggaction 0.0.23 → 0.0.26

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.
Files changed (228) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +2 -2
  3. package/knowledge/action-cards.json +1 -1
  4. package/knowledge/intent-taxonomy.json +1 -1
  5. package/knowledge/mcp-resources.json +1 -1
  6. package/knowledge/task-resolver.js +1 -1
  7. package/package.json +1 -1
  8. package/src/accessibility.js +13 -0
  9. package/src/actions/canvas/actions.js +2 -1
  10. package/src/actions/canvas/fitting.js +68 -13
  11. package/src/actions/charts/beeswarm.js +2 -1
  12. package/src/actions/charts/ecdf.js +2 -1
  13. package/src/actions/charts/endpoints.js +25 -16
  14. package/src/actions/charts/heatmap.js +6 -4
  15. package/src/actions/charts/missing.js +17 -0
  16. package/src/actions/charts/radar.js +70 -33
  17. package/src/actions/charts/raincloud.js +18 -4
  18. package/src/actions/charts/rug-strip.js +2 -2
  19. package/src/actions/charts/shared.js +17 -0
  20. package/src/actions/composition/actions.js +24 -7
  21. package/src/actions/composition/adjacent.js +90 -0
  22. package/src/actions/composition/index.js +11 -1
  23. package/src/actions/composition/layer.js +42 -0
  24. package/src/actions/composition/layerPlan.js +176 -0
  25. package/src/actions/composition/targets.js +105 -0
  26. package/src/actions/data/basicBin2d.js +1 -1
  27. package/src/actions/data/bin2d.js +2 -2
  28. package/src/actions/data/derived.js +3 -0
  29. package/src/actions/data/filter.js +1 -1
  30. package/src/actions/data/raincloudFilter.js +3 -1
  31. package/src/actions/encodings/appearance.js +26 -7
  32. package/src/actions/encodings/channels.js +1 -0
  33. package/src/actions/encodings/color/continuous.js +37 -34
  34. package/src/actions/encodings/color/index.js +57 -43
  35. package/src/actions/encodings/color/policy.js +15 -0
  36. package/src/actions/encodings/density/resolve.js +3 -2
  37. package/src/actions/encodings/density.js +2 -2
  38. package/src/actions/encodings/offset.js +2 -0
  39. package/src/actions/encodings/pathOrder.js +2 -0
  40. package/src/actions/encodings/position/areaAlignment.js +42 -0
  41. package/src/actions/encodings/position/index.js +37 -3
  42. package/src/actions/encodings/position/policies/line.js +1 -1
  43. package/src/actions/encodings/ranged.js +4 -2
  44. package/src/actions/encodings/remove.js +13 -10
  45. package/src/actions/encodings/ruleAppearance.js +14 -2
  46. package/src/actions/facets/actions.js +1 -1
  47. package/src/actions/guides/axes/axes.js +2 -1
  48. package/src/actions/guides/axes/components.js +5 -0
  49. package/src/actions/guides/axes/edit.js +1 -188
  50. package/src/actions/guides/axes/labels.js +2 -537
  51. package/src/actions/guides/axes/parallel/lifecycle.js +11 -4
  52. package/src/actions/guides/axes/parallel/policy.js +5 -2
  53. package/src/actions/guides/axes/parallel/resolve.js +3 -1
  54. package/src/actions/guides/axes/parallel.js +9 -4
  55. package/src/actions/guides/axes/tickGroups.js +16 -1
  56. package/src/actions/guides/axes/ticks.js +33 -24
  57. package/src/actions/guides/axes/titles.js +1 -389
  58. package/src/actions/guides/facadeLegend.js +3 -2
  59. package/src/actions/guides/grids/grid.js +3 -2
  60. package/src/actions/guides/grids/resolve.js +2 -1
  61. package/src/actions/guides/guides.js +3 -2
  62. package/src/actions/guides/legends/automatic.js +28 -0
  63. package/src/actions/guides/legends/blocks.js +15 -21
  64. package/src/actions/guides/legends/categorical/actions.js +1 -1
  65. package/src/actions/guides/legends/categorical/components.js +4 -3
  66. package/src/actions/guides/legends/categorical/layout.js +5 -2
  67. package/src/actions/guides/legends/categorical/options.js +9 -14
  68. package/src/actions/guides/legends/categorical/recipes.js +3 -0
  69. package/src/actions/guides/legends/categorical/resolve.js +7 -5
  70. package/src/actions/guides/legends/colorSize.js +51 -14
  71. package/src/actions/guides/legends/continuous/common.js +1 -1
  72. package/src/actions/guides/legends/continuous/gradient.js +1 -1
  73. package/src/actions/guides/legends/continuous/opacity.js +2 -0
  74. package/src/actions/guides/legends/edit.js +1 -1
  75. package/src/actions/guides/legends/focused.js +2 -2
  76. package/src/actions/guides/legends/group.js +1 -1
  77. package/src/actions/guides/legends/lane.js +1 -1
  78. package/src/actions/guides/legends/lifecycle.js +2 -2
  79. package/src/actions/guides/legends/remove.js +18 -4
  80. package/src/actions/guides/legends/size.js +1 -1
  81. package/src/actions/guides/legends/transition.js +15 -6
  82. package/src/actions/guides/polar/axes/facade.js +4 -2
  83. package/src/actions/guides/polar/axes/labels.js +6 -1
  84. package/src/actions/guides/polar/axes/shared.js +4 -4
  85. package/src/actions/guides/polar/axes/titles.js +13 -2
  86. package/src/actions/guides/polar/grids.js +6 -3
  87. package/src/actions/guides/tickValues.js +7 -1
  88. package/src/actions/index.js +2 -0
  89. package/src/actions/marks/area/actions.js +9 -2
  90. package/src/actions/marks/area/materialize.js +1 -1
  91. package/src/actions/marks/bar/materialize.js +4 -4
  92. package/src/actions/marks/lifecycle.js +14 -0
  93. package/src/actions/marks/line/actions.js +3 -0
  94. package/src/actions/marks/line/materialize.js +4 -2
  95. package/src/actions/marks/point/edit.js +2 -0
  96. package/src/actions/marks/point/jitter.js +2 -0
  97. package/src/actions/marks/point/materialize.js +5 -4
  98. package/src/actions/marks/point/packing.js +2 -0
  99. package/src/actions/marks/references.js +5 -3
  100. package/src/actions/marks/remove.js +16 -0
  101. package/src/actions/marks/shared.js +2 -1
  102. package/src/actions/marks/text/actions.js +5 -2
  103. package/src/actions/overlays/index.js +168 -0
  104. package/src/actions/overlays/snapshot.js +45 -0
  105. package/src/actions/primitives/editGraphics.js +21 -27
  106. package/src/actions/primitives/semanticAction.js +4 -5
  107. package/src/actions/primitives/semanticValidation/guide.js +1 -0
  108. package/src/actions/primitives/semanticValidation/layer.js +7 -0
  109. package/src/actions/scales/consumers/common.js +10 -0
  110. package/src/actions/scales/consumers/families.js +1 -1
  111. package/src/actions/scales/preview.js +1 -1
  112. package/src/actions/selection/actions.js +63 -11
  113. package/src/actions/titles/actions.js +3 -14
  114. package/src/actions/titles/resolve.js +23 -10
  115. package/src/core/action.js +41 -25
  116. package/src/core/compositionScales.js +12 -0
  117. package/src/core/compositionState.js +20 -3
  118. package/src/core/facetGap.js +44 -0
  119. package/src/core/immutable.js +11 -6
  120. package/src/core/programState.js +16 -0
  121. package/src/core/resourceReferences.js +19 -3
  122. package/src/core/validation.js +7 -0
  123. package/src/core/vocabulary.js +8 -2
  124. package/src/grammar/actionTargets.js +24 -0
  125. package/src/grammar/axisTextPlacement.js +1 -1
  126. package/src/grammar/bin2d.js +41 -4
  127. package/src/grammar/colorSizeLegend.js +56 -5
  128. package/src/grammar/displayLabels.js +10 -0
  129. package/src/grammar/facetHeaders.js +1 -1
  130. package/src/grammar/facets/dependencies.js +10 -3
  131. package/src/grammar/facets/guides.js +2 -1
  132. package/src/grammar/facets/legendIntent.js +7 -5
  133. package/src/grammar/legendBlocks.js +1 -1
  134. package/src/grammar/legendCombinations.js +1 -1
  135. package/src/grammar/legendGroups.js +1 -1
  136. package/src/grammar/legendItemsOffset.js +12 -0
  137. package/src/grammar/legendOverflow.js +25 -0
  138. package/src/grammar/lineSeries.js +1 -1
  139. package/src/grammar/markSelection.js +23 -0
  140. package/src/grammar/overlay.js +51 -0
  141. package/src/grammar/parallelCoordinates.js +1 -208
  142. package/src/grammar/pathCommands.js +8 -2
  143. package/src/grammar/pathSeries.js +1 -1
  144. package/src/grammar/plotComposition.js +152 -0
  145. package/src/grammar/pointShapes.js +18 -0
  146. package/src/grammar/scales/transformed.js +1 -1
  147. package/src/grammar/schemas/concreteGraphic.js +8 -1
  148. package/src/grammar/schemas/graphicBounds.js +8 -1
  149. package/src/grammar/schemas/graphicTree.js +18 -2
  150. package/src/grammar/schemas/semanticPath.js +3 -0
  151. package/src/grammar/strokeStyle.js +14 -8
  152. package/src/grammar/text.js +72 -0
  153. package/src/inspection.js +1 -1
  154. package/src/inspectionDescriptors.js +1 -1
  155. package/src/layout/composition.js +32 -10
  156. package/src/layout/compositionLegend.js +91 -0
  157. package/src/layout/facetContentFlow.js +15 -7
  158. package/src/layout/facetHeaderTitles.js +1 -1
  159. package/src/layout/facets.js +1 -1
  160. package/src/layout/labels.js +55 -27
  161. package/src/layout/legend.js +3 -2
  162. package/src/layout/legendGroup.js +1 -1
  163. package/src/layout/legendItems.js +7 -6
  164. package/src/layout/legendLane.js +1 -1
  165. package/src/layout/legendText.js +1 -1
  166. package/src/layout/title.js +1 -1
  167. package/src/materialization/adjacentSource.js +27 -0
  168. package/src/materialization/composition.js +165 -52
  169. package/src/materialization/compositionLegends.js +120 -0
  170. package/src/materialization/compositionScales.js +125 -0
  171. package/src/materialization/compositionTitle.js +96 -0
  172. package/src/materialization/facetGuides/edit.js +22 -2
  173. package/src/materialization/facetGuides/layout.js +1 -1
  174. package/src/materialization/facetGuides/owner.js +4 -3
  175. package/src/materialization/facetGuides/ownerLayout.js +12 -6
  176. package/src/materialization/facetGuides/placement.js +19 -22
  177. package/src/materialization/facetGuides/preparation.js +17 -5
  178. package/src/materialization/facetHeaders.js +5 -1
  179. package/src/materialization/facets.js +1 -1
  180. package/src/materialization/guides/layout.js +1 -1
  181. package/src/materialization/guides/resources.js +1 -1
  182. package/src/materialization/guides/visibility.js +105 -0
  183. package/src/materialization/legendClip.js +1 -1
  184. package/src/materialization/legendGroups.js +1 -1
  185. package/src/materialization/legendItemsOffset.js +68 -0
  186. package/src/materialization/legendSuppression.js +22 -0
  187. package/src/materialization/legends.js +3 -2
  188. package/src/materialization/marks/capabilities.js +4 -0
  189. package/src/materialization/marks/index.js +5 -1
  190. package/src/materialization/marks/pathOrder.js +1 -1
  191. package/src/materialization/marks/policies.js +2 -1
  192. package/src/materialization/overlayItems.js +154 -0
  193. package/src/materialization/planner.js +19 -4
  194. package/src/materialization/scales/policies/binnedPosition.js +43 -5
  195. package/src/materialization/scales/policies/series.js +12 -4
  196. package/src/materialization/scales/resolve.js +2 -0
  197. package/src/materialization/selection/items/bar.js +21 -3
  198. package/src/materialization/selection/items/common.js +18 -5
  199. package/src/materialization/selection/items/overlay.js +15 -0
  200. package/src/materialization/selection/items/path.js +1 -0
  201. package/src/materialization/selection/items/point.js +2 -3
  202. package/src/materialization/selection/items/tick.js +3 -12
  203. package/src/materialization/selection/path.js +2 -2
  204. package/src/materialization/selection/policies/bar.js +2 -0
  205. package/src/materialization/selection/policies/index.js +35 -1
  206. package/src/materialization/selection/policies/point.js +2 -0
  207. package/src/materialization/selection/policies/text.js +17 -0
  208. package/src/materialization/selection/rows.js +103 -0
  209. package/src/materialization/selection/state.js +19 -3
  210. package/src/materialization/selection/styles.js +8 -0
  211. package/src/materialization/text.js +6 -489
  212. package/src/materialization/textGeometry.js +503 -0
  213. package/src/materialization/textLines.js +20 -0
  214. package/src/persistence/validation.js +116 -25
  215. package/src/renderers/canvas/index.js +12 -7
  216. package/src/renderers/canvas/text.js +5 -5
  217. package/src/renderers/nativeBackend.js +25 -0
  218. package/src/renderers/pdf.js +3 -3
  219. package/src/renderers/png.js +4 -3
  220. package/src/renderers/svgSerialization.js +1 -643
  221. package/src/scene.js +5 -3
  222. package/src/selectors/plotComposition.js +115 -0
  223. package/src/selectors/selectionTargets.js +19 -0
  224. package/src/version.js +1 -1
  225. package/types/basic.d.ts +4 -2
  226. package/types/index.d.ts +14 -0
  227. package/types/inspection.d.ts +34 -0
  228. package/types/program.d.ts +572 -372
@@ -1,3 +1,4 @@
1
+ import { validateCompositionSpacing } from "../core/facetGap.js";
1
2
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
3
 
3
4
  const DIRECTIONS = ["horizontal", "vertical"];
@@ -11,12 +12,7 @@ export const DEFAULT_COMPOSITION_LAYOUT = cloneAndFreeze({
11
12
  padding: { top: 0, right: 0, bottom: 0, left: 0 }
12
13
  });
13
14
 
14
- export function validateCompositionSpacing(value, label) {
15
- if (!Number.isFinite(value) || value < 0) {
16
- throw new RangeError(`${label} must be a non-negative finite number.`);
17
- }
18
- return value;
19
- }
15
+ export { validateCompositionSpacing } from "../core/facetGap.js";
20
16
 
21
17
  function validatePaddingBase(base) {
22
18
  if (!isPlainObject(base)) {
@@ -86,7 +82,7 @@ export function normalizeCompositionChildren(children) {
86
82
  throw new TypeError(`Composition child ${index} must be a plain object.`);
87
83
  }
88
84
  const unknown = Object.keys(child).find(
89
- key => !["id", "width", "height", "widthMode", "heightMode"].includes(key)
85
+ key => !["id", "width", "height", "widthMode", "heightMode", "plot"].includes(key)
90
86
  );
91
87
  if (unknown !== undefined) {
92
88
  throw new Error(`Unknown composition child property "${unknown}".`);
@@ -116,7 +112,8 @@ export function normalizeCompositionChildren(children) {
116
112
  width: child.width,
117
113
  height: child.height,
118
114
  widthMode: child.widthMode ?? "explicit",
119
- heightMode: child.heightMode ?? "explicit"
115
+ heightMode: child.heightMode ?? "explicit",
116
+ ...(child.plot === undefined ? {} : { plot: validatePlacedBounds(child.plot, `Composition plot ${child.id}`) })
120
117
  });
121
118
  });
122
119
  }
@@ -150,10 +147,11 @@ export function resolveCompositionLayout({
150
147
  const resolvedDirection = normalizeDirection(direction);
151
148
  const resolvedChildren = normalizeCompositionChildren(children);
152
149
  const resolvedGap = validateCompositionSpacing(gap, "Composition gap");
153
- const resolvedAlign = normalizeCompositionAlign(align);
150
+ const resolvedAlign = align === "plot" ? "plot" : normalizeCompositionAlign(align);
154
151
  const resolvedPadding = normalizeCompositionPadding(padding);
155
152
  const horizontal = resolvedDirection === "horizontal";
156
- const sizedChildren = normalizeAutoCrossSize(resolvedChildren, horizontal);
153
+ const sizedChildren = resolvedAlign === "plot" ? resolvedChildren : normalizeAutoCrossSize(resolvedChildren, horizontal);
154
+ if (resolvedAlign === "plot") return resolvePlotAlignedComposition({ direction: resolvedDirection, children: sizedChildren, gap: resolvedGap, padding: resolvedPadding });
157
155
  const contentWidth = horizontal
158
156
  ? sizedChildren.reduce((sum, child) => sum + child.width, 0) +
159
157
  resolvedGap * (sizedChildren.length - 1)
@@ -307,3 +305,27 @@ export function resolvePlacedPlotBounds({ placements, plots } = {}) {
307
305
  height: bottom - top
308
306
  });
309
307
  }
308
+
309
+
310
+ function resolvePlotAlignedComposition({ direction, children, gap, padding }) {
311
+ const horizontal = direction === "horizontal";
312
+ const axis = horizontal ? "y" : "x", dimension = horizontal ? "height" : "width";
313
+ if (children.some(child => !child.plot)) throw new Error("Plot alignment requires a plot in every child.");
314
+ const crossSize = children[0].plot[dimension];
315
+ if (children.some(child => Math.abs(child.plot[dimension] - crossSize) > 1e-6)) {
316
+ throw new Error("Plot alignment requires equal cross-axis plot sizes after normalization.");
317
+ }
318
+ const leading = Math.max(...children.map(child => child.plot[axis]));
319
+ const trailing = Math.max(...children.map(child => child[dimension] - child.plot[axis] - crossSize));
320
+ let cursor = horizontal ? padding.left : padding.top;
321
+ const placements = children.map(child => {
322
+ const result = { id: child.id, width: child.width, height: child.height,
323
+ x: horizontal ? cursor : padding.left + leading - child.plot.x,
324
+ y: horizontal ? padding.top + leading - child.plot.y : cursor };
325
+ cursor += (horizontal ? child.width : child.height) + gap;
326
+ return result;
327
+ });
328
+ return cloneAndFreeze({ direction, gap, align: "plot", padding, children: placements,
329
+ width: horizontal ? cursor - gap + padding.right : padding.left + leading + crossSize + trailing + padding.right,
330
+ height: horizontal ? padding.top + leading + crossSize + trailing + padding.bottom : cursor - gap + padding.bottom });
331
+ }
@@ -0,0 +1,91 @@
1
+ import { textBoundsIntersect } from "../core/textMetrics.js";
2
+ import { SIDE_LEGEND_BLOCK_GAP, HORIZONTAL_LEGEND_BLOCK_GAP } from "./legendLane.js";
3
+ import { resolveLegendGroupPlacement } from "./legendGroup.js";
4
+
5
+ export function compositionLegendEdgeClearance(plot, retained, position, occupied) {
6
+ const side = position === 'left' || position === 'right';
7
+ const relevant = occupied ? retained.filter(value => side
8
+ ? value.top < occupied.bottom && value.bottom > occupied.top
9
+ : value.left < occupied.right && value.right > occupied.left) : retained;
10
+ return Math.max(0, ...relevant.map(value => position === 'right' ? value.right - plot.x - plot.width
11
+ : position === 'left' ? plot.x - value.left : position === 'top' ? plot.y - value.top
12
+ : value.bottom - plot.y - plot.height));
13
+ }
14
+
15
+ // Collected legends retain the native plot-relative anchor. Reuse available
16
+ // child margins; only occupied graphics can require an additional guide lane.
17
+ export function layoutCompositionLegend(base, plot, legend, title = {}, retained = []) {
18
+ if (!legend) return { layout: base };
19
+ const { bounds, placement } = legend;
20
+ const anchorBounds = legend.anchorBounds ?? bounds;
21
+ const width = bounds.right - bounds.left, height = bounds.bottom - bounds.top;
22
+ const { position = 'right', align = 'center', offset = 8, alongOffset = 0, at } = placement;
23
+ const inferred = placement.inferredOffset ?? legend.config.inferredOffset;
24
+ const outside = !at && ['left', 'right', 'top', 'bottom'].includes(position);
25
+ const group = { blockLayout: 'column', inferredOffset: inferred,
26
+ placement: at ? { at } : { position, align, offset, alongOffset } };
27
+ const place = (group, measured = bounds) => {
28
+ const [value] = resolveLegendGroupPlacement({ blocks: [{ id: 'shared', bounds: anchorBounds }],
29
+ group, plot, canvas: base, allowOverflow: true, deferCanvasFit: true });
30
+ return { left: measured.left + value.dx, right: measured.right + value.dx,
31
+ top: measured.top + value.dy, bottom: measured.bottom + value.dy };
32
+ };
33
+ let occupied = place(group);
34
+ if (outside && inferred) {
35
+ const clearance = compositionLegendEdgeClearance(plot, retained, position, place(group, anchorBounds));
36
+ occupied = place({ ...group, placement: { ...group.placement, offset: offset + clearance } });
37
+ } else if (outside && retained.some(value => textBoundsIntersect(occupied, value))) {
38
+ throw new Error('Shared composition legend overlaps retained chart content. Increase its offset.');
39
+ }
40
+ const titleSpace = side => title.config?.position === side ? title.reservation + 8 : 0;
41
+ const dx = Math.max(0, 8 + titleSpace('left') - occupied.left);
42
+ const dy = Math.max(0, 8 + titleSpace('top') - occupied.top);
43
+ const x = occupied.left + dx, y = occupied.top + dy;
44
+ const layout = { ...base,
45
+ width: Math.max(base.width + dx, x + width + 8 + titleSpace('right')),
46
+ height: Math.max(base.height + dy, y + height + 8 + titleSpace('bottom')),
47
+ children: base.children.map(child => ({ ...child, x: child.x + dx, y: child.y + dy })) };
48
+ return { layout, frame: { x, y, width, height } };
49
+ }
50
+
51
+ // Every displayed block uses the same parent frame. Compatible shared color
52
+ // explanations are deduplicated upstream; independent blocks remain distinct.
53
+ // Pack overlapping outside blocks along their edge, preserving authored gaps.
54
+ export function layoutCompositionLegends(base, plot, legends, title = {}, retained = []) {
55
+ let layout = base, currentPlot = plot, occupiedContent = retained;
56
+ const frames = new Map(), placed = [];
57
+ for (const legend of legends) {
58
+ let candidate = legend;
59
+ let result = layoutCompositionLegend(layout, currentPlot, candidate, title, occupiedContent);
60
+ const position = legend.placement.position ?? 'right';
61
+ const outside = !legend.placement.at && ['left', 'right', 'top', 'bottom'].includes(position);
62
+ if (outside) {
63
+ const side = ['left', 'right'].includes(position);
64
+ for (let attempt = 0; attempt < placed.length; attempt++) {
65
+ const dx = result.layout.children[0].x - layout.children[0].x;
66
+ const dy = result.layout.children[0].y - layout.children[0].y;
67
+ const f = result.frame;
68
+ const overlaps = placed.filter(previous => previous.position === position).map(previous => frames.get(previous.id))
69
+ .filter(other => textBoundsIntersect({ left: f.x, right: f.x + f.width, top: f.y, bottom: f.y + f.height },
70
+ { left: other.x + dx, right: other.x + dx + other.width, top: other.y + dy, bottom: other.y + dy + other.height }));
71
+ if (!overlaps.length) break;
72
+ const shift = side ? Math.max(...overlaps.map(other => other.y + dy + other.height)) + SIDE_LEGEND_BLOCK_GAP - f.y
73
+ : Math.max(...overlaps.map(other => other.x + dx + other.width)) + HORIZONTAL_LEGEND_BLOCK_GAP - f.x;
74
+ candidate = { ...candidate, placement: { ...candidate.placement,
75
+ alongOffset: (candidate.placement.alongOffset ?? 0) + shift } };
76
+ result = layoutCompositionLegend(layout, currentPlot, candidate, title, occupiedContent);
77
+ }
78
+ }
79
+ const dx = result.layout.children[0].x - layout.children[0].x;
80
+ const dy = result.layout.children[0].y - layout.children[0].y;
81
+ if (dx || dy) {
82
+ for (const [id, frame] of frames) frames.set(id, { ...frame, x: frame.x + dx, y: frame.y + dy });
83
+ occupiedContent = occupiedContent.map(b => ({ left: b.left + dx, right: b.right + dx, top: b.top + dy, bottom: b.bottom + dy }));
84
+ currentPlot = { ...currentPlot, x: currentPlot.x + dx, y: currentPlot.y + dy };
85
+ }
86
+ frames.set(legend.id, result.frame);
87
+ placed.push({ id: legend.id, position });
88
+ layout = result.layout;
89
+ }
90
+ return { layout, frames };
91
+ }
@@ -1,5 +1,5 @@
1
1
  import { cloneAndFreeze, isPlainObject } from "../core/immutable.js";
2
- import { validateCompositionSpacing } from "./composition.js";
2
+ import { normalizeFacetGap, facetGapAxis, assertFacetGapFinite } from "../core/facetGap.js";
3
3
 
4
4
  /** Pack ordered full content bounds without imposing common plot origins. */
5
5
  export function resolveFacetContentFlow({ children, columns, gap = 16 } = {}) {
@@ -11,7 +11,8 @@ export function resolveFacetContentFlow({ children, columns, gap = 16 } = {}) {
11
11
  throw new RangeError("Facet content flow columns must be a positive integer.");
12
12
  }
13
13
  columns = Math.min(columns, children.length);
14
- validateCompositionSpacing(gap, "Facet content flow gap");
14
+ gap = normalizeFacetGap(gap, "Facet content flow gap");
15
+ const gapX = facetGapAxis(gap, "x"), gapY = facetGapAxis(gap, "y");
15
16
  const ids = new Set();
16
17
  const rightExtents = Array(columns).fill(0);
17
18
  const bottomExtents = Array(Math.ceil(children.length / columns)).fill(0);
@@ -35,16 +36,23 @@ export function resolveFacetContentFlow({ children, columns, gap = 16 } = {}) {
35
36
  const columnOrigins = Array(columns).fill(0);
36
37
  let x = 0;
37
38
  const placed = cells.map(cell => {
38
- x = cell.column === 0 ? 0 : x + rightExtents[cell.column - 1] + gap +
39
+ x = cell.column === 0 ? 0 : x + rightExtents[cell.column - 1] + gapX +
39
40
  Math.max(0, Math.ceil(-cell.bounds.left));
40
41
  if (cell.row > 0) {
41
- columnOrigins[cell.column] += bottomExtents[cell.row - 1] + gap +
42
+ columnOrigins[cell.column] += bottomExtents[cell.row - 1] + gapY +
42
43
  Math.max(0, Math.ceil(-cell.bounds.top));
43
44
  }
44
45
  const y = columnOrigins[cell.column];
45
- if (![x, y, x + cell.bounds.left, x + cell.bounds.right,
46
- y + cell.bounds.top, y + cell.bounds.bottom].every(Number.isFinite)) {
47
- throw new RangeError("Facet content flow exceeds finite layout bounds.");
46
+ if (typeof gap === "number") {
47
+ if (![x, y, x + cell.bounds.left, x + cell.bounds.right,
48
+ y + cell.bounds.top, y + cell.bounds.bottom].every(Number.isFinite)) {
49
+ throw new RangeError("Facet content flow exceeds finite layout bounds.");
50
+ }
51
+ } else {
52
+ assertFacetGapFinite(gap, "x", [x, x + cell.bounds.left, x + cell.bounds.right],
53
+ "Facet content flow exceeds finite layout bounds.");
54
+ assertFacetGapFinite(gap, "y", [y, y + cell.bounds.top, y + cell.bounds.bottom],
55
+ "Facet content flow exceeds finite layout bounds.");
48
56
  }
49
57
  return {
50
58
  id: cell.id, column: cell.column, row: cell.row, x, y,
@@ -1 +1 @@
1
- import{resolveTextBounds as m}from"../core/textMetrics.js";const p=Object.freeze(["fontSize","fontFamily","fontWeight","color"]),b=Object.freeze({left:-Math.PI/2,right:Math.PI/2,top:0,bottom:0});function u(r,o,n,l){const i=m({...o,text:r,x:0,y:0,textAlign:"center",textBaseline:"middle"},l);return["left","right"].includes(n)?i.right-i.left:i.bottom-i.top}function M(r,o){const n=["left","right"].includes(o)?r.cellColumns?.[o]:r.cellRows[o];return n===void 0?0:["left","top"].includes(o)?n[0]:n.at(-1)}function T({roles:r,descriptors:o,space:n,headerLayout:l,profile:i,ids:g}){const a=[];for(const[x,f]of r){if(f.title===void 0)continue;const{side:t}=f,c=f.title,s={...Object.fromEntries(p.map(e=>[e,f[e]])),...c,side:t,align:c.align??"center",rotation:c.rotation??b[t]},d=Math.max(0,...o.filter(e=>e.config.side===t&&e.text!=="").map(e=>u(e.text,e.config,t,i)+e.config.offset+(e.clearance?.[t]??0)));s.offset=c.offset??Math.max(n[t],d)+8;const h=s.offset+u(c.text,s,t,i);l.outer[t]=Math.max(l.outer[t],h-n[t]-M(l,t),0),a.push({topology:"role-title",role:x,ids:g,text:c.text,config:s})}return a}export{T as prepareFacetHeaderTitles};
1
+ import{resolveTextBounds as m,resolveTextLines as b}from"../core/textMetrics.js";const p=Object.freeze(["fontSize","fontFamily","fontWeight","color"]),S=Object.freeze({left:-Math.PI/2,right:Math.PI/2,top:0,bottom:0});function d(i,n,o,c){const s=b(i,{fontSize:n.fontSize,align:"center",baseline:"middle",blockAlign:"middle"}),l=m({...n,text:i,x:0,y:0,...s.length>1?{lines:s}:{},textAlign:"center",textBaseline:"middle"},c);return["left","right"].includes(o)?l.right-l.left:l.bottom-l.top}function T(i,n){const o=["left","right"].includes(n)?i.cellColumns?.[n]:i.cellRows[n];return o===void 0?0:["left","top"].includes(n)?o[0]:o.at(-1)}function M({roles:i,descriptors:n,space:o,headerLayout:c,profile:s,ids:l}){const u=[];for(const[g,f]of i){if(f.title===void 0)continue;const{side:t}=f,r=f.title,a={...Object.fromEntries(p.map(e=>[e,f[e]])),...r,side:t,align:r.align??"center",rotation:r.rotation??S[t]},x=Math.max(0,...n.filter(e=>e.config.side===t&&e.text!=="").map(e=>d(e.text,e.config,t,s)+e.config.offset+(e.clearance?.[t]??0)));a.offset=r.offset??Math.max(o[t],x)+8;const h=a.offset+d(r.text,a,t,s);c.outer[t]=Math.max(c.outer[t],h-o[t]-T(c,t),0),u.push({topology:"role-title",role:g,ids:l,text:r.text,config:a})}return u}export{M as prepareFacetHeaderTitles};
@@ -1 +1 @@
1
- import{cloneAndFreeze as et,isPlainObject as U}from"../core/immutable.js";import{normalizeFacetTopLane as ot}from"./facetTopLane.js";import{annotateError as rt}from"../core/diagnostics.js";import{DEFAULT_COMPOSITION_LAYOUT as P,normalizeCompositionAlign as nt,normalizeCompositionChildren as at,normalizeCompositionPadding as it,validateCompositionSpacing as E}from"./composition.js";const lt=18,st=132,ct=0,ut=Object.freeze(["right","left","top","bottom"]);function mt(r,i){const f=r??i;if(!Number.isInteger(f)||f<=0)throw new RangeError("Facet columns must be a positive integer.");return Math.min(f,i)}function ht(r,i){if(!r.some(o=>o.row!==void 0||o.column!==void 0))return;if(r.some(o=>!(Number.isInteger(o.row)&&o.row>=0&&Number.isInteger(o.column)&&o.column>=0)))throw new RangeError("Facet grid children require non-negative integer row and column coordinates.");const d=r.map(o=>`${o.row}:${o.column}`);if(new Set(d).size!==d.length)throw new Error("Facet grid children must occupy unique row and column coordinates.");const w=Math.max(...r.map(o=>o.column))+1;if(i!==void 0&&i!==w)throw new RangeError("Facet grid columns must match the declared column domain.");return{columns:w,rows:Math.max(...r.map(o=>o.row))+1}}function Q(r,i){return i==="start"?0:i==="end"?r:r/2}const M=Object.freeze({top:0,right:0,bottom:0,left:0});function ft(r,i,f){if(r===void 0)return{outer:M,cellRows:{top:Array(i).fill(0),bottom:Array(i).fill(0)}};if(!U(r)||Object.keys(r).some(o=>!["outer","cellRows","cellColumns"].includes(o)))throw new TypeError("Facet headerLayout must contain outer and cellRows.");const d={...M,...r.outer??{}};if(!U(r.outer??{})||Object.keys(d).some(o=>!Object.hasOwn(M,o)))throw new TypeError("Facet headerLayout.outer must contain side reservations.");for(const[o,m]of Object.entries(d))E(m,`Facet headerLayout.outer.${o}`);const w={outer:d};for(const[o,m,x,z]of[["cellRows",["top","bottom"],i,"row"],["cellColumns",["left","right"],f,"column"]]){if(o==="cellColumns"&&r[o]===void 0)continue;const R=o==="cellRows"?r[o]??{}:r[o];if(!U(R)||Object.keys(R).some(l=>!m.includes(l)))throw new TypeError(`Facet headerLayout.${o} must contain ${m[0]} and ${m[1]} arrays.`);w[o]=Object.fromEntries(m.map(l=>{const F=R[l]??Array(x).fill(0);if(!Array.isArray(F)||F.length!==x)throw new RangeError(`Facet headerLayout.${o}.${l} must match ${z} count.`);return F.forEach((c,D)=>E(c,`Facet headerLayout.${o}.${l}[${D}]`)),[l,[...F]]}))}return w}function yt({children:r,columns:i,gap:f=P.gap,align:d=P.align,padding:w=P.padding,titleHeight:o=0,sharedLegend:m=!1,sharedLegendGap:x=lt,sharedLegendWidth:z=st,sharedLegendHeight:R=ct,sharedLegendPosition:l="right",headerLayout:F,spacing:c="canvas",topLane:D,plots:L}={}){if(!["canvas","plot","content"].includes(c))throw new Error(`Unknown facet spacing "${c}".`);const $=ot(D,c),H=new Map((L??[]).map(e=>[e.id,e])),W=r;let h=M;if(c==="plot"){if(!Array.isArray(L)||L.length!==r?.length||H.size!==L.length)throw new Error("Plot spacing requires one plot bounds record per child.");h={...M},r=r.map(e=>{const t=H.get(e.id);if(!t||![t.x,t.y,t.width,t.height].every(Number.isFinite)||t.x<0||t.y<0||t.width<=0||t.height<=0||t.x+t.width>e.width+1e-9||t.y+t.height>e.height+1e-9)throw new Error("Plot spacing requires valid contained child plot bounds.");return h.left=Math.max(h.left,t.x),h.top=Math.max(h.top,t.y),h.right=Math.max(h.right,e.width-t.x-t.width),h.bottom=Math.max(h.bottom,e.height-t.y-t.height),{...e,width:t.width,height:t.height}})}const V=r?.map(e=>e?.value),g=ht(r??[],i);if(c==="content"&&g!==void 0)throw new Error("Content flow requires a wrapped facet, not a keyed facet grid.");const I=at(r?.map(({value:e,row:t,column:s,...a})=>a)),u=g?.columns??mt(i,I.length),p=E(f,"Facet gap"),N=nt(d),k=it(w),y=Object.fromEntries(Object.keys(k).map(e=>[e,k[e]+h[e]])),j=E(o,"Facet title height"),Y=$===void 0?j:0;if(typeof m!="boolean")throw new TypeError("Facet sharedLegend must be a boolean.");const q=E(x,"Facet shared legend gap"),Z=E(z,"Facet shared legend width"),B=E(R,"Facet shared legend height");if(!ut.includes(l))throw new Error(`Unknown facet shared legend position "${l}".`);const O=["right","left"].includes(l),J=["left","top"].includes(l),G=m?q+(O?Z:B):0,A=g?.rows??Math.ceil(I.length/u);let n=ft(F,A,u);if(c==="plot"){let e=0;for(const[t,s]of[[n.cellRows.bottom,n.cellRows.top],[n.cellColumns?.right??[],n.cellColumns?.left??[]]])for(let a=1;a<t.length;a+=1)e=Math.max(e,t[a-1]+s[a]);if(e>p)throw rt(new Error("Plot spacing gap is too small for the facet headers."),{code:"invalid-value",optionPath:"gap",actual:p,limit:e});n={outer:{top:n.outer.top+n.cellRows.top[0],bottom:n.outer.bottom+n.cellRows.bottom.at(-1),left:n.outer.left+(n.cellColumns?.left[0]??0),right:n.outer.right+(n.cellColumns?.right.at(-1)??0)},cellRows:{top:Array(A).fill(0),bottom:Array(A).fill(0)}},$!==void 0&&(n.outer.top=0)}const T=n.cellColumns??{left:Array(u).fill(0),right:Array(u).fill(0)},C=Array(u).fill(-1/0),v=Array(A).fill(-1/0);I.forEach((e,t)=>{const s=g===void 0?t%u:r[t].column,a=g===void 0?Math.floor(t/u):r[t].row;C[s]=Math.max(C[s],e.width),v[a]=Math.max(v[a],e.height)});const X=C.map((e,t)=>y.left+n.outer.left+C.slice(0,t).reduce((s,a,_)=>s+T.left[_]+a+T.right[_],0)+p*t+T.left[t]),tt=v.map((e,t)=>Y+y.top+n.outer.top+v.slice(0,t).reduce((s,a,_)=>s+n.cellRows.top[_]+a+n.cellRows.bottom[_],0)+p*t+n.cellRows.top[t]),b=I.map((e,t)=>{const s=g===void 0?t%u:r[t].column,a=g===void 0?Math.floor(t/u):r[t].row;return{id:e.id,value:V[t],column:s,row:a,x:X[s]+(J&&O?G:0)+Q(C[s]-e.width,N),y:tt[a]+(J&&!O?G:0)+Q(v[a]-e.height,N),width:e.width,height:e.height}}),S=y.left+n.outer.left+C.reduce((e,t)=>e+t,0)+T.left.reduce((e,t)=>e+t,0)+T.right.reduce((e,t)=>e+t,0)+p*Math.max(0,u-1)+n.outer.right+y.right,K=Y+y.top+n.outer.top+v.reduce((e,t)=>e+t,0)+n.cellRows.top.reduce((e,t)=>e+t,0)+n.cellRows.bottom.reduce((e,t)=>e+t,0)+p*Math.max(0,A-1)+n.outer.bottom+y.bottom;if(c==="plot")for(let e=0;e<b.length;e+=1){const t=H.get(b[e].id);b[e].x-=t.x,b[e].y-=t.y,b[e].width=W[e].width,b[e].height=W[e].height}return et({...c!=="canvas"?{spacing:c}:{},...$===void 0?{}:{topLane:$},columns:u,rows:A,gap:p,align:N,padding:k,titleHeight:j,headerLayout:n,gridWidth:S,width:S+(O?G:0),height:K+(O?0:G),children:b,...m?{legend:{position:l,x:l==="right"?S+q:0,y:l==="bottom"?K+q:l==="top"?j:j+y.top+30,width:Z,height:B}}:{}})}export{lt as DEFAULT_FACET_LEGEND_GAP,ct as DEFAULT_FACET_LEGEND_HEIGHT,st as DEFAULT_FACET_LEGEND_WIDTH,yt as resolveFacetLayout};
1
+ import{cloneAndFreeze as at,isPlainObject as P}from"../core/immutable.js";import{normalizeFacetGap as it,facetGapAxis as V,assertFacetGapFinite as lt}from"../core/facetGap.js";import{normalizeFacetTopLane as st}from"./facetTopLane.js";import{annotateError as tt}from"../core/diagnostics.js";import{DEFAULT_COMPOSITION_LAYOUT as W,normalizeCompositionAlign as ct,normalizeCompositionChildren as ut,normalizeCompositionPadding as mt,validateCompositionSpacing as M}from"./composition.js";const ft=18,ht=132,dt=0,pt=Object.freeze(["right","left","top","bottom"]);function gt(r,l){const p=r??l;if(!Number.isInteger(p)||p<=0)throw new RangeError("Facet columns must be a positive integer.");return Math.min(p,l)}function wt(r,l){if(!r.some(o=>o.row!==void 0||o.column!==void 0))return;if(r.some(o=>!(Number.isInteger(o.row)&&o.row>=0&&Number.isInteger(o.column)&&o.column>=0)))throw new RangeError("Facet grid children require non-negative integer row and column coordinates.");const g=r.map(o=>`${o.row}:${o.column}`);if(new Set(g).size!==g.length)throw new Error("Facet grid children must occupy unique row and column coordinates.");const y=Math.max(...r.map(o=>o.column))+1;if(l!==void 0&&l!==y)throw new RangeError("Facet grid columns must match the declared column domain.");return{columns:y,rows:Math.max(...r.map(o=>o.row))+1}}function et(r,l){return l==="start"?0:l==="end"?r:r/2}const _=Object.freeze({top:0,right:0,bottom:0,left:0});function yt(r,l,p){if(r===void 0)return{outer:_,cellRows:{top:Array(l).fill(0),bottom:Array(l).fill(0)}};if(!P(r)||Object.keys(r).some(o=>!["outer","cellRows","cellColumns"].includes(o)))throw new TypeError("Facet headerLayout must contain outer and cellRows.");const g={..._,...r.outer??{}};if(!P(r.outer??{})||Object.keys(g).some(o=>!Object.hasOwn(_,o)))throw new TypeError("Facet headerLayout.outer must contain side reservations.");for(const[o,m]of Object.entries(g))M(m,`Facet headerLayout.outer.${o}`);const y={outer:g};for(const[o,m,L,D]of[["cellRows",["top","bottom"],l,"row"],["cellColumns",["left","right"],p,"column"]]){if(o==="cellColumns"&&r[o]===void 0)continue;const O=o==="cellRows"?r[o]??{}:r[o];if(!P(O)||Object.keys(O).some(s=>!m.includes(s)))throw new TypeError(`Facet headerLayout.${o} must contain ${m[0]} and ${m[1]} arrays.`);y[o]=Object.fromEntries(m.map(s=>{const x=O[s]??Array(L).fill(0);if(!Array.isArray(x)||x.length!==L)throw new RangeError(`Facet headerLayout.${o}.${s} must match ${D} count.`);return x.forEach((c,H)=>M(c,`Facet headerLayout.${o}.${s}[${H}]`)),[s,[...x]]}))}return y}function Ct({children:r,columns:l,gap:p=W.gap,align:g=W.align,padding:y=W.padding,titleHeight:o=0,sharedLegend:m=!1,sharedLegendGap:L=ft,sharedLegendWidth:D=ht,sharedLegendHeight:O=dt,sharedLegendPosition:s="right",headerLayout:x,spacing:c="canvas",topLane:H,plots:$}={}){if(!["canvas","plot","content"].includes(c))throw new Error(`Unknown facet spacing "${c}".`);const G=st(H,c),N=new Map(($??[]).map(e=>[e.id,e])),Y=r;let f=_;if(c==="plot"){if(!Array.isArray($)||$.length!==r?.length||N.size!==$.length)throw new Error("Plot spacing requires one plot bounds record per child.");f={..._},r=r.map(e=>{const t=N.get(e.id);if(!t||![t.x,t.y,t.width,t.height].every(Number.isFinite)||t.x<0||t.y<0||t.width<=0||t.height<=0||t.x+t.width>e.width+1e-9||t.y+t.height>e.height+1e-9)throw new Error("Plot spacing requires valid contained child plot bounds.");return f.left=Math.max(f.left,t.x),f.top=Math.max(f.top,t.y),f.right=Math.max(f.right,e.width-t.x-t.width),f.bottom=Math.max(f.bottom,e.height-t.y-t.height),{...e,width:t.width,height:t.height}})}const ot=r?.map(e=>e?.value),b=wt(r??[],l);if(c==="content"&&b!==void 0)throw new Error("Content flow requires a wrapped facet, not a keyed facet grid.");const I=ut(r?.map(({value:e,row:t,column:i,...a})=>a)),u=b?.columns??gt(l,I.length),h=it(p,"Facet gap"),X=V(h,"x"),Z=V(h,"y"),k=ct(g),q=mt(y),E=Object.fromEntries(Object.keys(q).map(e=>[e,q[e]+f[e]])),j=M(o,"Facet title height"),B=G===void 0?j:0;if(typeof m!="boolean")throw new TypeError("Facet sharedLegend must be a boolean.");const S=M(L,"Facet shared legend gap"),J=M(D,"Facet shared legend width"),K=M(O,"Facet shared legend height");if(!pt.includes(s))throw new Error(`Unknown facet shared legend position "${s}".`);const F=["right","left"].includes(s),Q=["left","top"].includes(s),A=m?S+(F?J:K):0,C=b?.rows??Math.ceil(I.length/u);let n=yt(x,C,u);if(c==="plot"){const e={x:0,y:0};for(const[t,i,a]of[["y",n.cellRows.bottom,n.cellRows.top],["x",n.cellColumns?.right??[],n.cellColumns?.left??[]]])for(let d=1;d<i.length;d+=1)e[t]=Math.max(e[t],i[d-1]+a[d]);if(typeof h=="number"){const t=Math.max(e.y,e.x);if(t>h)throw tt(new Error("Plot spacing gap is too small for the facet headers."),{code:"invalid-value",optionPath:"gap",actual:h,limit:t})}else for(const t of["x","y"])if(e[t]>h[t])throw tt(new Error("Plot spacing gap is too small for the facet headers."),{code:"invalid-value",optionPath:`gap.${t}`,actual:h[t],limit:e[t]});n={outer:{top:n.outer.top+n.cellRows.top[0],bottom:n.outer.bottom+n.cellRows.bottom.at(-1),left:n.outer.left+(n.cellColumns?.left[0]??0),right:n.outer.right+(n.cellColumns?.right.at(-1)??0)},cellRows:{top:Array(C).fill(0),bottom:Array(C).fill(0)}},G!==void 0&&(n.outer.top=0)}const T=n.cellColumns??{left:Array(u).fill(0),right:Array(u).fill(0)},v=Array(u).fill(-1/0),R=Array(C).fill(-1/0);I.forEach((e,t)=>{const i=b===void 0?t%u:r[t].column,a=b===void 0?Math.floor(t/u):r[t].row;v[i]=Math.max(v[i],e.width),R[a]=Math.max(R[a],e.height)});const rt=v.map((e,t)=>E.left+n.outer.left+v.slice(0,t).reduce((i,a,d)=>i+T.left[d]+a+T.right[d],0)+X*t+T.left[t]),nt=R.map((e,t)=>B+E.top+n.outer.top+R.slice(0,t).reduce((i,a,d)=>i+n.cellRows.top[d]+a+n.cellRows.bottom[d],0)+Z*t+n.cellRows.top[t]),w=I.map((e,t)=>{const i=b===void 0?t%u:r[t].column,a=b===void 0?Math.floor(t/u):r[t].row;return{id:e.id,value:ot[t],column:i,row:a,x:rt[i]+(Q&&F?A:0)+et(v[i]-e.width,k),y:nt[a]+(Q&&!F?A:0)+et(R[a]-e.height,k),width:e.width,height:e.height}}),z=E.left+n.outer.left+v.reduce((e,t)=>e+t,0)+T.left.reduce((e,t)=>e+t,0)+T.right.reduce((e,t)=>e+t,0)+X*Math.max(0,u-1)+n.outer.right+E.right,U=B+E.top+n.outer.top+R.reduce((e,t)=>e+t,0)+n.cellRows.top.reduce((e,t)=>e+t,0)+n.cellRows.bottom.reduce((e,t)=>e+t,0)+Z*Math.max(0,C-1)+n.outer.bottom+E.bottom;if(c==="plot")for(let e=0;e<w.length;e+=1){const t=N.get(w[e].id);w[e].x-=t.x,w[e].y-=t.y,w[e].width=Y[e].width,w[e].height=Y[e].height}if(typeof h!="number")for(const[e,t,i]of[["x",z+(F?A:0),"width"],["y",U+(F?0:A),"height"]])lt(h,e,[t,...w.flatMap(a=>[a[e],a[i],a[e]+a[i]])],"Facet layout exceeds finite bounds.");return at({...c!=="canvas"?{spacing:c}:{},...G===void 0?{}:{topLane:G},columns:u,rows:C,gap:h,align:k,padding:q,titleHeight:j,headerLayout:n,gridWidth:z,width:z+(F?A:0),height:U+(F?0:A),children:w,...m?{legend:{position:s,x:s==="right"?z+S:0,y:s==="bottom"?U+S:s==="top"?j:j+E.top+30,width:J,height:K}}:{}})}export{ft as DEFAULT_FACET_LEGEND_GAP,dt as DEFAULT_FACET_LEGEND_HEIGHT,ht as DEFAULT_FACET_LEGEND_WIDTH,Ct as resolveFacetLayout};
@@ -517,11 +517,45 @@ function textBounds(item, offset = { x: 0, y: 0 }, profile) {
517
517
  }, profile);
518
518
  }
519
519
 
520
- function candidateScore(bounds, boundary, placed, candidate, order) {
520
+ // Query only nearby rectangles. Oversized bounds use the ordered fallback so
521
+ // extreme dimensions cannot allocate an unbounded number of grid cells.
522
+ function labelBoundsIndex() {
523
+ const cells = new Map(), rectangles = [], wide = [];
524
+ const keys = bounds => {
525
+ const x0 = Math.floor(bounds.left / 32), x1 = Math.floor(bounds.right / 32);
526
+ const y0 = Math.floor(bounds.top / 32), y1 = Math.floor(bounds.bottom / 32);
527
+ if (![x0, x1, y0, y1].every(Number.isSafeInteger) || (x1 - x0 + 1) * (y1 - y0 + 1) > 256) return undefined;
528
+ const result = [];
529
+ for (let x = x0; x <= x1; x++) for (let y = y0; y <= y1; y++) result.push(`${x},${y}`);
530
+ return result;
531
+ };
532
+ return {
533
+ add(bounds) {
534
+ const id = rectangles.push(bounds) - 1, slots = keys(bounds);
535
+ if (!slots) { wide.push(id); return; }
536
+ for (const key of slots) {
537
+ if (!cells.has(key)) cells.set(key, []);
538
+ cells.get(key).push(id);
539
+ }
540
+ },
541
+ near(bounds) {
542
+ const slots = keys(bounds);
543
+ if (!slots) return rectangles.map((bounds, id) => ({ bounds, id }));
544
+ const ids = new Set(wide);
545
+ for (const key of slots) for (const id of cells.get(key) ?? []) ids.add(id);
546
+ // Preserve stable summation and tie breaking from the ordered scan.
547
+ return [...ids].sort((a, b) => a - b).map(id => ({ bounds: rectangles[id], id }));
548
+ }
549
+ };
550
+ }
551
+
552
+ function candidateScore(bounds, boundary, placed, candidate, order, account) {
553
+ const nearby = placed.near(bounds);
554
+ account(nearby.length + 1);
521
555
  return {
522
556
  outside: overflow(bounds, boundary),
523
- overlap: placed.reduce(
524
- (area, prior) => area + intersectionArea(bounds, prior),
557
+ overlap: nearby.reduce(
558
+ (area, prior) => area + intersectionArea(bounds, prior.bounds),
525
559
  0
526
560
  ),
527
561
  distanceSquared: candidate.distanceSquared,
@@ -537,21 +571,15 @@ function compareScore(first, second) {
537
571
  first.order - second.order;
538
572
  }
539
573
 
540
- function overlapPairs(items) {
541
- const pairs = [];
542
- for (let first = 0; first < items.length; first += 1) {
543
- for (let second = first + 1; second < items.length; second += 1) {
544
- if (
545
- intersectionArea(
546
- items[first].collisionBounds,
547
- items[second].collisionBounds
548
- ) > 0
549
- ) {
550
- pairs.push([items[first].id, items[second].id]);
551
- }
552
- }
553
- }
554
- return pairs;
574
+ function overlapPairs(items, account) {
575
+ const pairs = [], index = labelBoundsIndex();
576
+ items.forEach((item, second) => {
577
+ const nearby = index.near(item.collisionBounds);
578
+ account(nearby.length + 1);
579
+ for (const first of nearby) if (intersectionArea(first.bounds, item.collisionBounds) > 0) pairs.push([first.id, second]);
580
+ index.add(item.collisionBounds);
581
+ });
582
+ return pairs.sort((a, b) => a[0] - b[0] || a[1] - b[1]).map(pair => pair.map(id => items[id].id));
555
583
  }
556
584
 
557
585
  export function resolveLabelLayout({ items, bounds, ...options } = {}, profile) {
@@ -563,15 +591,14 @@ export function resolveLabelLayout({ items, bounds, ...options } = {}, profile)
563
591
  const ids = new Set();
564
592
  for (const item of items) validateItem(item, ids);
565
593
  const candidates = enumerateLabelOffsets(policy);
566
- validateWorkLimit(
567
- items.length ** 2 * candidates.length,
568
- "Label layout work"
569
- );
594
+ let work = 0;
595
+ const account = amount => validateWorkLimit(work += amount, "Label layout work");
596
+ account(items.length);
570
597
  const base = items.map(item => ({
571
598
  id: item.id,
572
599
  collisionBounds: expanded(textBounds(item, undefined, profile), policy.padding)
573
600
  }));
574
- const placed = [];
601
+ const placed = labelBoundsIndex();
575
602
  const resolved = [];
576
603
  for (const item of items) {
577
604
  let best;
@@ -583,7 +610,8 @@ export function resolveLabelLayout({ items, bounds, ...options } = {}, profile)
583
610
  bounds,
584
611
  placed,
585
612
  candidate,
586
- order
613
+ order,
614
+ account
587
615
  );
588
616
  if (best === undefined || compareScore(score, best.score) < 0) {
589
617
  best = { candidate, bounds: boundsAtCandidate, collisionBounds, score };
@@ -602,11 +630,11 @@ export function resolveLabelLayout({ items, bounds, ...options } = {}, profile)
602
630
  bounds: best.bounds,
603
631
  collisionBounds: best.collisionBounds
604
632
  };
605
- placed.push(resolvedItem.collisionBounds);
633
+ placed.add(resolvedItem.collisionBounds);
606
634
  resolved.push(resolvedItem);
607
635
  }
608
- const beforePairs = overlapPairs(base);
609
- const afterPairs = overlapPairs(resolved);
636
+ const beforePairs = overlapPairs(base, account);
637
+ const afterPairs = overlapPairs(resolved, account);
610
638
  const outside = resolved
611
639
  .filter(item => overflow(item.collisionBounds, bounds) > 0)
612
640
  .map(item => item.id);
@@ -1,3 +1,4 @@
1
+ import { resolveLegendTitleText } from "./legendText.js";
1
2
  import { PLOT_LEGEND_POSITIONS } from "../core/vocabulary.js";
2
3
  import {
3
4
  formatVisibleText,
@@ -5,7 +6,7 @@ import {
5
6
  } from "../core/textMetrics.js";
6
7
 
7
8
  export function measureLegendTextWidth(value, style = { fontSize: 12 }, profile) {
8
- return measureTextWidth(formatVisibleText(value), style, profile);
9
+ return resolveLegendTitleText(String(value), style, profile).width;
9
10
  }
10
11
 
11
12
  // A conservative line-height reserve, not a font-specific glyph measurement.
@@ -37,7 +38,7 @@ export function resolveLegendGrid(config, width, count, symbolHeight, profile) {
37
38
  config.itemGap * Math.max(0, actualColumns - 1);
38
39
  const rowHeight = Math.max(
39
40
  config.labels.fontSize,
40
- symbolHeight
41
+ symbolHeight, ...labels.map(label => resolveLegendTitleText(label, config.labels, profile).height)
41
42
  );
42
43
  const gridHeight = rowHeight * rowCount +
43
44
  config.itemGap * Math.max(0, rowCount - 1);
@@ -1 +1 @@
1
- import{canvasOverflowError as M}from"./canvas.js";import{PLOT_LEGEND_POSITIONS as N}from"./legend.js";import{SIDE_LEGEND_BLOCK_GAP as P,HORIZONTAL_LEGEND_BLOCK_GAP as C}from"./legendLane.js";function v({blocks:u,group:O,plot:e,canvas:c,allowOverflow:_=!1,deferCanvasFit:G=!1}){if(u.length===0)return[];const{placement:t,blockLayout:f}=O,w=N.includes(t.position),p=["left","right"].includes(t.position),x=f==="column"||f==="wrap"&&p,y=w||p||t.at!==void 0?P:C,l=e.width-(w?t.offset*2:0);let h=0,E=0,m=0;const d=u.map(i=>{const o=i.bounds.right-i.bounds.left,L=i.bounds.bottom-i.bounds.top;if(f==="wrap"&&!x&&!w&&o>l&&!_)throw new Error("Horizontal legend block requires more plot width.");!x&&f==="wrap"&&h>0&&h+o>l&&(h=0,E+=m+y,m=0);const I={id:i.id,x:h,y:E,width:o,height:L};return x?E+=L+y:(h+=o+y,m=Math.max(m,L)),I}),s=Math.max(...d.map(i=>i.x+i.width)),a=Math.max(...d.map(i=>i.y+i.height));if(f==="wrap"&&t.position==="top"){const i=new Map;for(const o of d)i.set(o.y,Math.max(i.get(o.y)??0,o.height));for(const o of d)o.y=a-o.y-i.get(o.y)}let n,r;if(t.at!==void 0)n=e.x+t.at.x,r=e.y+t.at.y;else if(w){if(s+2*t.offset>e.width||a+2*t.offset>e.height)throw new Error("In-plot legend group does not fit the plot inset.");n=t.position.endsWith("left")?e.x+t.offset:e.x+e.width-t.offset-s,r=t.position.startsWith("top")?e.y+t.offset:e.y+e.height-t.offset-a}else p?(n=t.position==="right"?e.x+e.width+t.offset:e.x-t.offset-s,r=e.y):(n=t.align==="left"?e.x:t.align==="right"?e.x+e.width-s:e.x+(e.width-s)/2,r=t.position==="top"?e.y-t.offset-a:e.y+e.height+t.offset);const g={left:n,top:r,right:n+s,bottom:r+a};if(!G&&(g.left<0||g.top<0||g.right>c.width||g.bottom>c.height))throw t.at!==void 0||O.inferredOffset!==!0?new Error("Legend group requires more Canvas space."):M("Legend group requires more Canvas space.",[g],c);return d.map((i,o)=>({id:i.id,dx:n+i.x-u[o].bounds.left,dy:r+i.y-u[o].bounds.top}))}export{v as resolveLegendGroupPlacement};
1
+ import{canvasOverflowError as N}from"./canvas.js";import{annotateError as C}from"../core/diagnostics.js";import{PLOT_LEGEND_POSITIONS as D}from"./legend.js";import{SIDE_LEGEND_BLOCK_GAP as H,HORIZONTAL_LEGEND_BLOCK_GAP as P}from"./legendLane.js";function S({blocks:m,group:l,plot:t,canvas:x,allowOverflow:G=!1,deferCanvasFit:I=!1,deferPlotFit:L=!1}){if(m.length===0)return[];const{placement:e,blockLayout:h}=l,d=D.includes(e.position),w=["left","right"].includes(e.position),y=h==="column"||h==="wrap"&&w,p=d||w||e.at!==void 0?H:P,_=t.width-(d?e.offset*2:0);let a=0,E=0,c=0;const g=m.map(i=>{const o=i.bounds.right-i.bounds.left,O=i.bounds.bottom-i.bounds.top;if(h==="wrap"&&!y&&!d&&o>_&&!G)throw new Error("Horizontal legend block requires more plot width.");!y&&h==="wrap"&&a>0&&a+o>_&&(a=0,E+=c+p,c=0);const M={id:i.id,x:a,y:E,width:o,height:O};return y?E+=O+p:(a+=o+p,c=Math.max(c,O)),M}),s=Math.max(...g.map(i=>i.x+i.width)),u=Math.max(...g.map(i=>i.y+i.height));if(h==="wrap"&&e.position==="top"){const i=new Map;for(const o of g)i.set(o.y,Math.max(i.get(o.y)??0,o.height));for(const o of g)o.y=u-o.y-i.get(o.y)}let r,f;if(e.at!==void 0)r=t.x+e.at.x,f=t.y+e.at.y;else if(d){if(!L&&(s+2*e.offset>t.width||u+2*e.offset>t.height))throw new Error("In-plot legend group does not fit the plot inset.");r=e.position.endsWith("left")?t.x+e.offset:t.x+t.width-e.offset-s,f=e.position.startsWith("top")?t.y+e.offset:t.y+t.height-e.offset-u}else w?(r=e.position==="right"?t.x+t.width+e.offset:t.x-e.offset-s,f=t.y):(r=e.align==="left"?t.x:e.align==="right"?t.x+t.width-s:t.x+(t.width-s)/2,f=e.position==="top"?t.y-e.offset-u:t.y+t.height+e.offset);e.at===void 0&&(w?f+=e.alongOffset??0:r+=e.alongOffset??0);const n={left:r,top:f,right:r+s,bottom:f+u};if(d&&!L&&(n.left<t.x||n.top<t.y||n.right>t.x+t.width||n.bottom>t.y+t.height))throw new Error("In-plot legend group does not fit the plot inset.");if(!I&&(n.left<0||n.top<0||n.right>x.width||n.bottom>x.height)){const i=N("Legend group requires more Canvas space.",[n],x);throw e.at!==void 0||l.inferredOffset!==!0?C(i,{requiresExplicitCanvas:!0}):i}return g.map((i,o)=>({id:i.id,dx:r+i.x-m[o].bounds.left,dy:f+i.y-m[o].bounds.top}))}export{S as resolveLegendGroupPlacement};
@@ -1,6 +1,6 @@
1
1
  import { measureTextWidth, resolveTextBounds } from "../core/textMetrics.js";
2
2
  import { alignLegendStart, resolveLegendGrid, isPlotLegend, legendLabelLineHeight } from "./legend.js";
3
- import { alignLegendTitle, resolveLegendTitleText } from "./legendText.js";
3
+ import { alignLegendLabels, alignLegendTitle, resolveLegendTitleText } from "./legendText.js";
4
4
  import {resolveLegendContentSpacing} from "./legendSpacing.js";
5
5
 
6
6
  // Measure item content before choosing its edge. Family owners supply the
@@ -32,7 +32,7 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas, pr
32
32
  let itemRows;
33
33
  let title;
34
34
  if (config.layout === "legacy-bottom") {
35
- const widths = labels.map(text => sampleWidth + config.labels.offset + measureTextWidth(text, config.labels, profile));
35
+ const widths = labels.map(text => sampleWidth + config.labels.offset + resolveLegendTitleText(text, config.labels, profile).width);
36
36
  const width = widths.reduce((sum, value) => sum + value, 0) + config.itemGap * (labels.length - 1);
37
37
  const x = config.align === "center" ? (canvas.width - width) / 2 : alignLegendStart(plot, width, config.align);
38
38
  let cursor = x;
@@ -52,7 +52,7 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas, pr
52
52
  const width = Math.max(grid.gridWidth, titleWidth);
53
53
  const x = config.position === "right" ? plot.x + plot.width + config.offset
54
54
  : plot.x - config.offset - width;
55
- const itemHeight = Math.max(sampleHeight, legendLabelLineHeight(config.labels.fontSize));
55
+ const itemHeight = Math.max(sampleHeight, legendLabelLineHeight(config.labels.fontSize), ...labels.map(text => resolveLegendTitleText(text, config.labels, profile).height));
56
56
  const pitch = Math.max(config.itemGap, itemHeight);
57
57
  let cursor = x;
58
58
  const columnStarts = grid.columnWidths.map(columnWidth => {
@@ -69,7 +69,7 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas, pr
69
69
  text: config.title, ...config.titleStyle, lines: titleText.lines,
70
70
  textAlign: "left", textBaseline: "middle" }, profile);
71
71
  const labelTops = labels.map(text => resolveTextBounds({
72
- x: 0, y: 0, text, ...config.labels,
72
+ x: 0, y: 0, text, ...config.labels, lines: resolveLegendTitleText(text, config.labels, profile).lines,
73
73
  textAlign: "left", textBaseline: "middle"
74
74
  }, profile).top);
75
75
  const itemTop = Math.min(...sampleBounds.map(bounds => bounds.top), ...labelTops);
@@ -104,9 +104,10 @@ export function resolveLegendItemLayout(plot, config, labels, symbol, canvas, pr
104
104
  : { x: x + width / 2, y: y + titleHeight / 2, align: "center" };
105
105
  }
106
106
  const textBounds = (x, y, text, style, align = "left") => resolveTextBounds({
107
- x, y, text, ...style, textAlign: align, textBaseline: "middle"
107
+ x, y, text, ...style, lines: resolveLegendTitleText(text, style, profile).lines, textAlign: align, textBaseline: "middle"
108
108
  }, profile);
109
- const labelBounds = labels.map((label,index) => textBounds(labelX[index],itemY[index],label,config.labels));
109
+ if (config.labels.align !== undefined) labelX = alignLegendLabels(labelX.map((x, index) => ({ x, y: itemY[index], align: "left" })), labels, config.labels, profile).map(position => position.x);
110
+ const labelBounds = labels.map((label,index) => textBounds(labelX[index],itemY[index],label,config.labels,config.labels.align ?? "left"));
110
111
  const symbolItemBounds = symbolX.map((x, index) => {
111
112
  const local = sampleBounds[index];
112
113
  return { left: x + local.left, right: x + local.right,
@@ -1 +1 @@
1
- import{canvasOverflowError as S}from"./canvas.js";import{unionBounds as _}from"../core/textMetrics.js";const N=24,$=40,v=16,X=44,U=12;function K(t){return t!==void 0&&["top","bottom"].includes(t.position)&&t.layout!=="legacy-bottom"}function W({plot:t,canvas:o,config:n,bounds:r}){if(!K(n))throw new Error("Single legend placement requires a horizontal edge layout.");const i=n.align==="left"?t.x-r.left:n.align==="right"?t.x+t.width-r.right:t.x+t.width/2-B(r.left,r.right),e=n.position==="top"?t.y-n.offset-r.bottom:t.y+t.height+n.offset-r.top,s=w(r,i,e);if(s.left<0||s.right>o.width||s.top<0||s.bottom>o.height)throw S(`Legend layout requires more ${n.position}-margin or Canvas space.`,[s],o);return{dx:i,dy:e,occupied:s}}function B(t,o){const n=t+o,r=Number.isFinite(n)?n/2:t/2+o/2;return r===0?0:r}function T(t){return t===!1?0:t.padding+t.lineWidth/2}function A(t,o){let n=0,r=0;for(const i of t){const e=i.inset??0;if(i.preserveGrid){n=Math.min(n,-e),r=Math.max(r,i.bounds.right-i.bounds.left+e);continue}i.title!==void 0&&(r=Math.max(r,i.title.width+e)),n=Math.min(n,v+i.symbol.left-i.symbol.centerX-e),r=Math.max(r,v+i.symbol.right-i.symbol.centerX+e,o+i.labels.width+e)}return{left:n,right:r}}function Y(t,o,n){const r=o-t.bounds.top,i={left:t.bounds.left,right:t.bounds.right,top:t.bounds.top+r,bottom:t.bounds.bottom+r};return{id:t.id,dy:r,titleDx:t.title===void 0?void 0:t.preserveGrid?-t.bounds.left:-t.title.x,symbolDx:t.preserveGrid?-t.bounds.left:v-t.symbol.centerX,labelDx:t.preserveGrid?-t.bounds.left:n-t.labels.x,bounds:i}}function H(t,o){return t.left<o.right&&t.right>o.left&&t.top<o.bottom&&t.bottom>o.top}function j({side:t,plot:o,canvas:n,groups:r,axisBounds:i}){if(!["right","left"].includes(t))throw new Error(`Unsupported side legend lane "${t}".`);const e=r.flatMap(c=>c.blocks);if(e.length<2)return;const s=Math.max(X,...e.filter(c=>!c.preserveGrid).map(c=>v+Math.abs(c.labels.x-c.symbol.centerX))),f=A(e,s),g=e.reduce((c,x)=>Math.max(c,x.offset),-1/0),l=t==="right"?o.x+o.width+g:o.x-g-f.right,h=l+v,d=l+s;let y=o.y;const z=[],E=[],D=[];for(const c of r){const x=T(c.border);D.length>0&&(y+=x);const G=[];for(const C of c.blocks){const I=Y(C,y,s);G.push(I),z.push({...I,titleDx:I.titleDx===void 0?void 0:I.titleDx+l,symbolDx:I.symbolDx+l,labelDx:I.labelDx+l}),y=I.bounds.bottom+N}const L=_(G.map(C=>C.bounds)),u=A(c.blocks,s),b={left:l+u.left,right:l+u.right,top:L.top,bottom:L.bottom},k={left:b.left-x,right:b.right+x,top:b.top-x,bottom:b.bottom+x};D.push(k),c.backgroundId!==void 0&&E.push({id:c.backgroundId,x:b.left-c.border.padding,y:b.top-c.border.padding,width:b.right-b.left+c.border.padding*2,height:b.bottom-b.top+c.border.padding*2}),y=k.bottom+N}const a=_(D),p=o.y-a.top,m=w(a,0,p);if(m.left<0||m.right>n.width||m.top<0||m.bottom>n.height)throw S(`Legend lane requires more ${t}-margin or vertical Canvas space.`,[m],n);if(i!==void 0&&H(m,i))throw new Error(`${t[0].toUpperCase()}${t.slice(1)} legend lane and y-axis guides require more margin space.`);return{side:t,titleStartX:l,symbolCenterX:h,labelStartX:d,placements:z.map(c=>({...c,dy:c.dy+p,bounds:w(c.bounds,0,p)})),backgrounds:E.map(c=>({...c,y:c.y+p})),occupied:m}}function J({side:t,plot:o,canvas:n,bounds:r}){if(!["left","right"].includes(t))throw new Error("Single side legend placement requires a left or right edge.");const i=o.y-r.top,e=w(r,0,i);if(e.left<0||e.right>n.width||e.top<0||e.bottom>n.height)throw S(`Legend layout requires more ${t}-margin or Canvas space.`,[e],n);return{dy:i,occupied:e}}function w(t,o,n){return{left:t.left+o,right:t.right+o,top:t.top+n,bottom:t.bottom+n}}function q(t,o){return{left:t.left-o,right:t.right+o,top:t.top-o,bottom:t.bottom+o}}function O(t,o,n=!1){const r=[];let i=[],e=o.x;for(const s of t){const f=q(s.horizontal,s.inset);if(f.right-f.left>o.width&&!n)throw new Error("Horizontal legend block requires more plot width.");let l=e-f.left,h=w(f,l,0);i.length>0&&h.right>o.x+o.width&&(r.push(i),i=[],e=o.x,l=e-f.left,h=w(f,l,0)),i.push({group:s,interval:h,dx:l}),e=h.right+$}return i.length>0&&r.push(i),r}function P(t){const o=t.filter(e=>e.group.title!==void 0&&e.group.inline!==!0),n=o[0]?.group.title.y,r=o.length===0?0:o.reduce((e,s)=>Math.max(e,s.group.title.bounds.bottom-s.group.title.y),-1/0),i=n===void 0?B(t[0].group.element.top,t[0].group.element.bottom):n+r+U;return t.map(({group:e,dx:s})=>{if(e.atomic){const h=i-e.element.top,d=w(e.content,s,h);return{id:e.id,dx:s,titleDy:h,contentDy:h,foreground:d,occupied:q(d,e.inset),padding:e.padding,backgroundId:e.backgroundId}}const f=n===void 0?i-B(e.element.top,e.element.bottom):i-e.element.top,g=e.title===void 0?0:e.inline===!0?f:n-e.title.y,l=_([...e.title===void 0?[]:[w(e.title.bounds,s,g)],w(e.content,s,f)]);return{id:e.id,dx:s,titleDy:g,contentDy:f,foreground:l,occupied:q(l,e.inset),padding:e.padding,backgroundId:e.backgroundId}})}function R(t,o){return{...t,titleDy:t.titleDy+o,contentDy:t.contentDy+o,foreground:w(t.foreground,0,o),occupied:w(t.occupied,0,o)}}function Q({edge:t,plot:o,canvas:n,groups:r,collisionBounds:i=[],allowOverflow:e=!1}){if(!["top","bottom"].includes(t))throw new Error(`Unsupported horizontal legend lane "${t}".`);if(r.length<2)return;const s=O(r,o,e),f=s.map(P),g=[];let l;for(let d=0;d<f.length;d+=1){const y=f[d],z=_(y.map(a=>a.occupied));let E;if(d===0){const a=t==="top"?s[d].reduce((p,m)=>Math.max(p,m.group.horizontal.bottom),-1/0):s[d].reduce((p,m)=>Math.min(p,m.group.horizontal.top),1/0);E=t==="top"?a-z.bottom:a-z.top}else E=t==="bottom"?l.bottom+$-z.top:l.top-$-z.bottom;const D=y.map(a=>R(a,E));g.push(...D),l=_(D.map(a=>a.occupied))}const h=_(g.map(d=>d.occupied));if(g.some(d=>d.occupied.left<0||d.occupied.right>n.width||d.occupied.top<0||d.occupied.bottom>n.height))throw S(`Legend lane requires more ${t}-margin or Canvas space.`,[h],n);if(g.some(d=>i.some(y=>H(d.occupied,y)))){const d=t==="top"?"chart titles":"x-axis guides";throw new Error(`${t[0].toUpperCase()}${t.slice(1)} legend lane and ${d} require more margin space.`)}return{edge:t,placements:g.map(d=>({...d,...d.backgroundId===void 0?{}:{background:{id:d.backgroundId,x:d.foreground.left-d.padding,y:d.foreground.top-d.padding,width:d.foreground.right-d.foreground.left+d.padding*2,height:d.foreground.bottom-d.foreground.top+d.padding*2}}})),occupied:h,rowCount:s.length}}function V({edge:t,plot:o,canvas:n,groups:r,align:i,offset:e,border:s,backgroundId:f,collisionBounds:g=[],allowOverflow:l=!1}){const h=T(s),d={...o,x:o.x+h,width:o.width-h*2},z=O(r,d,l).map(P);let E=0;const D=[];for(const u of z){const b=_(u.map(M=>M.occupied)),k=t==="top"?E-b.bottom:E-b.top,C=u.map(M=>R(M,k));D.push(...C);const I=_(C.map(M=>M.occupied));E=t==="top"?I.top-$:I.bottom+$}const a=_(D.map(u=>u.occupied)),p=q(a,h),m=p.right-p.left,x=(i==="left"?o.x:i==="right"?o.x+o.width-m:o.x+(o.width-m)/2)-p.left,G=t==="top"?o.y-e-p.bottom:o.y+o.height+e-p.top,L=w(p,x,G);if(L.left<0||L.right>n.width||L.top<0||L.bottom>n.height)throw S(`Combined legend requires more ${t}-margin or Canvas space.`,[L],n);if(g.some(u=>H(L,u)))throw new Error(`Combined ${t} legend and ${t==="top"?"chart titles":"x-axis guides"} require more margin space.`);return{placements:D.map(u=>({...u,dx:u.dx+x,titleDy:u.titleDy+G,contentDy:u.contentDy+G,...u.backgroundId===void 0?{}:{background:{id:u.backgroundId,x:u.foreground.left+x-u.padding,y:u.foreground.top+G-u.padding,width:u.foreground.right-u.foreground.left+u.padding*2,height:u.foreground.bottom-u.foreground.top+u.padding*2}}})),occupied:L,background:f===void 0?void 0:{id:f,x:a.left+x-s.padding,y:a.top+G-s.padding,width:a.right-a.left+s.padding*2,height:a.bottom-a.top+s.padding*2}}}export{$ as HORIZONTAL_LEGEND_BLOCK_GAP,U as HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP,N as SIDE_LEGEND_BLOCK_GAP,X as SIDE_LEGEND_LABEL_START,v as SIDE_LEGEND_SYMBOL_CENTER,K as isHorizontalEdgeLegend,V as resolveHorizontalLegendGroup,Q as resolveHorizontalLegendLane,j as resolveSideLegendLane,W as resolveSingleHorizontalLegendPlacement,J as resolveSingleSideLegendPlacement};
1
+ import{canvasOverflowError as $}from"./canvas.js";import{unionBounds as z}from"../core/textMetrics.js";const N=24,v=40,k=16,X=44,U=12;function K(t){return t!==void 0&&["top","bottom"].includes(t.position)&&t.layout!=="legacy-bottom"}function W({plot:t,canvas:o,config:n,bounds:r}){if(!K(n))throw new Error("Single legend placement requires a horizontal edge layout.");const i=n.align==="left"?t.x-r.left:n.align==="right"?t.x+t.width-r.right:t.x+t.width/2-B(r.left,r.right),e=n.position==="top"?t.y-n.offset-r.bottom:t.y+t.height+n.offset-r.top,s=w(r,i,e);if(s.left<0||s.right>o.width||s.top<0||s.bottom>o.height)throw $(`Legend layout requires more ${n.position}-margin or Canvas space.`,[s],o);return{dx:i,dy:e,occupied:s}}function B(t,o){const n=t+o,r=Number.isFinite(n)?n/2:t/2+o/2;return r===0?0:r}function T(t){return t===!1?0:t.padding+t.lineWidth/2}function A(t,o){let n=0,r=0;for(const i of t){const e=i.inset??0;if(i.preserveGrid){n=Math.min(n,-e),r=Math.max(r,i.bounds.right-i.bounds.left+e);continue}i.title!==void 0&&(r=Math.max(r,i.title.width+e)),n=Math.min(n,k+i.symbol.left-i.symbol.centerX-e),r=Math.max(r,k+i.symbol.right-i.symbol.centerX+e,o+i.labels.width+e)}return{left:n,right:r}}function Y(t,o,n){const r=o-t.bounds.top,i={left:t.bounds.left,right:t.bounds.right,top:t.bounds.top+r,bottom:t.bounds.bottom+r};return{id:t.id,dy:r,titleDx:t.title===void 0?void 0:t.preserveGrid?-t.bounds.left:-t.title.x,symbolDx:t.preserveGrid?-t.bounds.left:k-t.symbol.centerX,labelDx:t.preserveGrid?-t.bounds.left:n-t.labels.x,bounds:i}}function H(t,o){return t.left<o.right&&t.right>o.left&&t.top<o.bottom&&t.bottom>o.top}function j({side:t,plot:o,canvas:n,groups:r,axisBounds:i,allowOverflow:e=!1}){if(!["right","left"].includes(t))throw new Error(`Unsupported side legend lane "${t}".`);const s=r.flatMap(c=>c.blocks);if(s.length<2)return;const a=Math.max(X,...s.filter(c=>!c.preserveGrid).map(c=>k+Math.abs(c.labels.x-c.symbol.centerX))),p=A(s,a),h=s.reduce((c,m)=>Math.max(c,m.offset),-1/0),u=t==="right"?o.x+o.width+h:o.x-h-p.right,d=u+k,G=u+a;let y=o.y;const D=[],I=[],f=[];for(const c of r){const m=T(c.border);f.length>0&&(y+=m);const L=[];for(const C of c.blocks){const b=Y(C,y,a);L.push(b),D.push({...b,titleDx:b.titleDx===void 0?void 0:b.titleDx+u,symbolDx:b.symbolDx+u,labelDx:b.labelDx+u}),y=b.bounds.bottom+N}const l=z(L.map(C=>C.bounds)),M=A(c.blocks,a),x={left:u+M.left,right:u+M.right,top:l.top,bottom:l.bottom},S={left:x.left-m,right:x.right+m,top:x.top-m,bottom:x.bottom+m};f.push(S),c.backgroundId!==void 0&&I.push({id:c.backgroundId,x:x.left-c.border.padding,y:x.top-c.border.padding,width:x.right-x.left+c.border.padding*2,height:x.bottom-x.top+c.border.padding*2}),y=S.bottom+N}const g=z(f),E=o.y-g.top,_=w(g,0,E);if(!e&&(_.left<0||_.right>n.width||_.top<0||_.bottom>n.height))throw $(`Legend lane requires more ${t}-margin or vertical Canvas space.`,[_],n);if(i!==void 0&&H(_,i))throw new Error(`${t[0].toUpperCase()}${t.slice(1)} legend lane and y-axis guides require more margin space.`);return{side:t,titleStartX:u,symbolCenterX:d,labelStartX:G,placements:D.map(c=>({...c,dy:c.dy+E,bounds:w(c.bounds,0,E)})),backgrounds:I.map(c=>({...c,y:c.y+E})),occupied:_}}function J({side:t,plot:o,canvas:n,bounds:r}){if(!["left","right"].includes(t))throw new Error("Single side legend placement requires a left or right edge.");const i=o.y-r.top,e=w(r,0,i);if(e.left<0||e.right>n.width||e.top<0||e.bottom>n.height)throw $(`Legend layout requires more ${t}-margin or Canvas space.`,[e],n);return{dy:i,occupied:e}}function w(t,o,n){return{left:t.left+o,right:t.right+o,top:t.top+n,bottom:t.bottom+n}}function q(t,o){return{left:t.left-o,right:t.right+o,top:t.top-o,bottom:t.bottom+o}}function O(t,o,n=!1){const r=[];let i=[],e=o.x;for(const s of t){const a=q(s.horizontal,s.inset);if(a.right-a.left>o.width&&!n)throw new Error("Horizontal legend block requires more plot width.");let h=e-a.left,u=w(a,h,0);i.length>0&&u.right>o.x+o.width&&(r.push(i),i=[],e=o.x,h=e-a.left,u=w(a,h,0)),i.push({group:s,interval:u,dx:h}),e=u.right+v}return i.length>0&&r.push(i),r}function P(t){const o=t.filter(e=>e.group.title!==void 0&&e.group.inline!==!0),n=o[0]?.group.title.y,r=o.length===0?0:o.reduce((e,s)=>Math.max(e,s.group.title.bounds.bottom-s.group.title.y),-1/0),i=n===void 0?B(t[0].group.element.top,t[0].group.element.bottom):n+r+U;return t.map(({group:e,dx:s})=>{if(e.atomic){const u=i-e.element.top,d=w(e.content,s,u);return{id:e.id,dx:s,titleDy:u,contentDy:u,foreground:d,occupied:q(d,e.inset),padding:e.padding,backgroundId:e.backgroundId}}const a=n===void 0?i-B(e.element.top,e.element.bottom):i-e.element.top,p=e.title===void 0?0:e.inline===!0?a:n-e.title.y,h=z([...e.title===void 0?[]:[w(e.title.bounds,s,p)],w(e.content,s,a)]);return{id:e.id,dx:s,titleDy:p,contentDy:a,foreground:h,occupied:q(h,e.inset),padding:e.padding,backgroundId:e.backgroundId}})}function R(t,o){return{...t,titleDy:t.titleDy+o,contentDy:t.contentDy+o,foreground:w(t.foreground,0,o),occupied:w(t.occupied,0,o)}}function Q({edge:t,plot:o,canvas:n,groups:r,collisionBounds:i=[],allowOverflow:e=!1}){if(!["top","bottom"].includes(t))throw new Error(`Unsupported horizontal legend lane "${t}".`);if(r.length<2)return;const s=O(r,o,e),a=s.map(P),p=[];let h;for(let d=0;d<a.length;d+=1){const G=a[d],y=z(G.map(f=>f.occupied));let D;if(d===0){const f=t==="top"?s[d].reduce((g,E)=>Math.max(g,E.group.horizontal.bottom),-1/0):s[d].reduce((g,E)=>Math.min(g,E.group.horizontal.top),1/0);D=t==="top"?f-y.bottom:f-y.top}else D=t==="bottom"?h.bottom+v-y.top:h.top-v-y.bottom;const I=G.map(f=>R(f,D));p.push(...I),h=z(I.map(f=>f.occupied))}const u=z(p.map(d=>d.occupied));if(p.some(d=>d.occupied.left<0||d.occupied.right>n.width||d.occupied.top<0||d.occupied.bottom>n.height))throw $(`Legend lane requires more ${t}-margin or Canvas space.`,[u],n);if(p.some(d=>i.some(G=>H(d.occupied,G)))){const d=t==="top"?"chart titles":"x-axis guides";throw new Error(`${t[0].toUpperCase()}${t.slice(1)} legend lane and ${d} require more margin space.`)}return{edge:t,placements:p.map(d=>({...d,...d.backgroundId===void 0?{}:{background:{id:d.backgroundId,x:d.foreground.left-d.padding,y:d.foreground.top-d.padding,width:d.foreground.right-d.foreground.left+d.padding*2,height:d.foreground.bottom-d.foreground.top+d.padding*2}}})),occupied:u,rowCount:s.length}}function V({edge:t,plot:o,canvas:n,groups:r,align:i,offset:e,border:s,backgroundId:a,collisionBounds:p=[],allowOverflow:h=!1}){const u=T(s),d={...o,x:o.x+u,width:o.width-u*2},y=O(r,d,h).map(P);let D=0;const I=[];for(const l of y){const M=z(l.map(b=>b.occupied)),x=t==="top"?D-M.bottom:D-M.top,S=l.map(b=>R(b,x));I.push(...S);const C=z(S.map(b=>b.occupied));D=t==="top"?C.top-v:C.bottom+v}const f=z(I.map(l=>l.occupied)),g=q(f,u),E=g.right-g.left,c=(i==="left"?o.x:i==="right"?o.x+o.width-E:o.x+(o.width-E)/2)-g.left,m=t==="top"?o.y-e-g.bottom:o.y+o.height+e-g.top,L=w(g,c,m);if(L.left<0||L.right>n.width||L.top<0||L.bottom>n.height)throw $(`Combined legend requires more ${t}-margin or Canvas space.`,[L],n);if(p.some(l=>H(L,l)))throw new Error(`Combined ${t} legend and ${t==="top"?"chart titles":"x-axis guides"} require more margin space.`);return{placements:I.map(l=>({...l,dx:l.dx+c,titleDy:l.titleDy+m,contentDy:l.contentDy+m,...l.backgroundId===void 0?{}:{background:{id:l.backgroundId,x:l.foreground.left+c-l.padding,y:l.foreground.top+m-l.padding,width:l.foreground.right-l.foreground.left+l.padding*2,height:l.foreground.bottom-l.foreground.top+l.padding*2}}})),occupied:L,background:a===void 0?void 0:{id:a,x:f.left+c-s.padding,y:f.top+m-s.padding,width:f.right-f.left+s.padding*2,height:f.bottom-f.top+s.padding*2}}}export{v as HORIZONTAL_LEGEND_BLOCK_GAP,U as HORIZONTAL_LEGEND_TITLE_ELEMENT_GAP,N as SIDE_LEGEND_BLOCK_GAP,X as SIDE_LEGEND_LABEL_START,k as SIDE_LEGEND_SYMBOL_CENTER,K as isHorizontalEdgeLegend,V as resolveHorizontalLegendGroup,Q as resolveHorizontalLegendLane,j as resolveSideLegendLane,W as resolveSingleHorizontalLegendPlacement,J as resolveSingleSideLegendPlacement};
@@ -1 +1 @@
1
- import{resolveTextBounds as f,resolveTextLines as m}from"../core/textMetrics.js";function s(e,t,n){const l=m(String(e),{fontSize:t.fontSize,align:"left",baseline:"middle",blockAlign:"middle"}),o=l.length>1?l:void 0,i=f({x:0,y:0,text:String(e),...t,lines:o,textAlign:"left",textBaseline:"middle"},n);return{lines:o,width:i.right-i.left,height:i.bottom-i.top,extraHeight:i.bottom-i.top-t.fontSize}}function h(e,t,n,l,o){if(n.align===void 0)return e;const i=s(t,n,o),r=f({x:e.x,y:e.y,text:t,...n,lines:i.lines,textAlign:e.align,textBaseline:"middle"},o),a=Math.min(r.left,...l.map(g=>g.left)),d=Math.max(r.right,...l.map(g=>g.right));return{...e,x:n.align==="left"?a:n.align==="right"?d:(a+d)/2,align:n.align}}export{h as alignLegendTitle,s as resolveLegendTitleText};
1
+ import{resolveTextBounds as m,resolveTextLines as x}from"../core/textMetrics.js";function u(n,r,t){const f=x(String(n),{fontSize:r.fontSize,align:"left",baseline:"middle",blockAlign:"middle"}),g=f.length>1?f:void 0,e=m({x:0,y:0,text:String(n),...r,lines:g,textAlign:"left",textBaseline:"middle"},t);return{lines:g,width:e.right-e.left,height:e.bottom-e.top,extraHeight:e.bottom-e.top-r.fontSize}}function p(n,r,t,f,g){if(t.align===void 0)return n;const e=u(r,t,g),i=m({x:n.x,y:n.y,text:r,...t,lines:e.lines,textAlign:n.align,textBaseline:"middle"},g),a=Math.min(i.left,...f.map(o=>o.left)),l=Math.max(i.right,...f.map(o=>o.right));return{...n,x:t.align==="left"?a:t.align==="right"?l:(a+l)/2,align:t.align}}function b(n,r,t,f){if(t.align===void 0)return n;const g=new Map,e=n.map(i=>`${i.x}:${i.align??"left"}`);return n.forEach((i,a)=>{const l=u(r[a],t,f),o=i.align==="right"?l.width:i.align==="center"?l.width/2:0,h=i.x-o,c=h+l.width,d=g.get(e[a]);g.set(e[a],{left:Math.min(d?.left??h,h),right:Math.max(d?.right??c,c)})}),n.map((i,a)=>{const l=g.get(e[a]);return{...i,x:t.align==="left"?l.left:t.align==="right"?l.right:(l.left+l.right)/2,align:t.align}})}export{b as alignLegendLabels,p as alignLegendTitle,u as resolveLegendTitleText};
@@ -21,7 +21,7 @@ export function buildTitleReadingBlock({ text, subtitle }, config, profile) {
21
21
  style: config.subtitleStyle, profile
22
22
  });
23
23
  const titleLineHeight = config.lineHeight ?? config.titleStyle.fontSize * 1.2;
24
- const subtitleLineHeight = config.lineHeight ??
24
+ const subtitleLineHeight = config.subtitleLineHeight ?? config.lineHeight ??
25
25
  config.subtitleStyle.fontSize * 1.2;
26
26
  const titleCenters = lineCenters(
27
27
  titleLines,
@@ -0,0 +1,27 @@
1
+ import { isPlainObject } from "../core/immutable.js";
2
+ import { normalizeActionTargets } from "../grammar/actionTargets.js";
3
+ import { resolveEligibleLayer } from "../selectors/layers.js";
4
+ import { ownership } from "../selectors/markOwners.js";
5
+
6
+ // Creation and inspection share the same explicit source ownership contract.
7
+ export function resolveAdjacentSource(program, source) {
8
+ const grouped = program.compositionSpec && program.compositionSpec.type !== "facet";
9
+ const scoped = isPlainObject(source);
10
+ if (grouped && !scoped) throw new Error("Adding beside a concat requires source: { childPath, id }.");
11
+ let view = program, id = source;
12
+ if (scoped) {
13
+ const selector = normalizeActionTargets(source)[0];
14
+ if (selector.id === undefined) throw new Error("Adjacent source requires an explicit mark id.");
15
+ id = selector.id;
16
+ for (const child of selector.childPath) {
17
+ if (view.compositionSpec?.type === "facet") throw new Error("Select the facet source view, not a generated facet cell.");
18
+ if (!Object.hasOwn(view.children, child)) throw new Error(`Unknown adjacent source child "${child}".`);
19
+ view = view.children[child];
20
+ }
21
+ }
22
+ if (view.compositionSpec && view.compositionSpec.type !== "facet") throw new Error("Adjacent source must identify a unit or facet view.");
23
+ const owners = ownership(view);
24
+ const layer = resolveEligibleLayer(view, { target: id, targetOption: "source", label: "adjacent chart source",
25
+ predicate: layer => !owners.has(layer.id) && !layer.derivedBindings?.overlay && layer.data !== undefined });
26
+ return { view, layer };
27
+ }