@opendata-ai/openchart-engine 8.0.0-rc.10 → 8.0.0-rc.11

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 (270) hide show
  1. package/dist/index.js +2 -3
  2. package/dist/index.js.map +1 -1
  3. package/package.json +3 -4
  4. package/src/__test-fixtures__/specs.ts +0 -182
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  6. package/src/__tests__/annotation-behavior.test.ts +0 -852
  7. package/src/__tests__/axes.test.ts +0 -1579
  8. package/src/__tests__/compile-animation.test.ts +0 -161
  9. package/src/__tests__/compile-chart.test.ts +0 -1303
  10. package/src/__tests__/compile-footnotes.test.ts +0 -277
  11. package/src/__tests__/compile-layer.test.ts +0 -969
  12. package/src/__tests__/compile-snapshot.test.ts +0 -156
  13. package/src/__tests__/compound-labels.test.ts +0 -147
  14. package/src/__tests__/dimensions.test.ts +0 -918
  15. package/src/__tests__/encoding-sugar.test.ts +0 -390
  16. package/src/__tests__/facet.test.ts +0 -359
  17. package/src/__tests__/helpers/invariants.ts +0 -299
  18. package/src/__tests__/known-bugs.test.ts +0 -566
  19. package/src/__tests__/layout-fuzz.test.ts +0 -358
  20. package/src/__tests__/layout-invariants.test.ts +0 -535
  21. package/src/__tests__/legend.test.ts +0 -721
  22. package/src/__tests__/mark-keys.test.ts +0 -294
  23. package/src/__tests__/ranking-recipes.test.ts +0 -260
  24. package/src/__tests__/rotation.test.ts +0 -83
  25. package/src/__tests__/scales.test.ts +0 -323
  26. package/src/__tests__/series-search.test.ts +0 -92
  27. package/src/__tests__/spec-sugar.test.ts +0 -801
  28. package/src/__tests__/you-draw-it.test.ts +0 -160
  29. package/src/annotations/__tests__/compute.test.ts +0 -1672
  30. package/src/annotations/__tests__/geometry.test.ts +0 -66
  31. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  32. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  33. package/src/annotations/__tests__/placement.test.ts +0 -316
  34. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  35. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  36. package/src/annotations/__tests__/thinning.test.ts +0 -285
  37. package/src/annotations/collisions.ts +0 -326
  38. package/src/annotations/compute.ts +0 -333
  39. package/src/annotations/constants.ts +0 -162
  40. package/src/annotations/geometry.ts +0 -376
  41. package/src/annotations/move.ts +0 -135
  42. package/src/annotations/placement.ts +0 -636
  43. package/src/annotations/position.ts +0 -131
  44. package/src/annotations/resolve-range.ts +0 -117
  45. package/src/annotations/resolve-refline.ts +0 -157
  46. package/src/annotations/resolve-text.ts +0 -545
  47. package/src/annotations/rich-text.ts +0 -65
  48. package/src/annotations/thinning.ts +0 -190
  49. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  50. package/src/barlist/compile-barlist.ts +0 -391
  51. package/src/barlist/types.ts +0 -28
  52. package/src/charts/__tests__/post-process.test.ts +0 -237
  53. package/src/charts/__tests__/registry.test.ts +0 -116
  54. package/src/charts/__tests__/utils.test.ts +0 -195
  55. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  56. package/src/charts/_shared/density-filter.ts +0 -26
  57. package/src/charts/_shared/format-label-value.ts +0 -16
  58. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  59. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  60. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  61. package/src/charts/bar/compute.ts +0 -539
  62. package/src/charts/bar/index.ts +0 -60
  63. package/src/charts/bar/labels.ts +0 -311
  64. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  65. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  66. package/src/charts/beeswarm/compute.ts +0 -203
  67. package/src/charts/beeswarm/dodge.ts +0 -155
  68. package/src/charts/beeswarm/index.ts +0 -32
  69. package/src/charts/builtin.ts +0 -75
  70. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  71. package/src/charts/calendar/compute.ts +0 -319
  72. package/src/charts/calendar/index.ts +0 -32
  73. package/src/charts/column/__tests__/compute.test.ts +0 -540
  74. package/src/charts/column/__tests__/labels.test.ts +0 -179
  75. package/src/charts/column/compute.ts +0 -493
  76. package/src/charts/column/index.ts +0 -53
  77. package/src/charts/column/labels.ts +0 -229
  78. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  79. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  80. package/src/charts/dot/compute.ts +0 -285
  81. package/src/charts/dot/index.ts +0 -62
  82. package/src/charts/dot/labels.ts +0 -121
  83. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  84. package/src/charts/line/__tests__/curves.test.ts +0 -51
  85. package/src/charts/line/__tests__/labels.test.ts +0 -94
  86. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  87. package/src/charts/line/area.ts +0 -484
  88. package/src/charts/line/compute.ts +0 -356
  89. package/src/charts/line/curves.ts +0 -85
  90. package/src/charts/line/index.ts +0 -176
  91. package/src/charts/line/labels.ts +0 -177
  92. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  93. package/src/charts/parliament/compute.ts +0 -313
  94. package/src/charts/parliament/index.ts +0 -32
  95. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  96. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  97. package/src/charts/pie/compute.ts +0 -324
  98. package/src/charts/pie/index.ts +0 -49
  99. package/src/charts/pie/labels.ts +0 -143
  100. package/src/charts/post-process.ts +0 -229
  101. package/src/charts/range/__tests__/compute.test.ts +0 -486
  102. package/src/charts/range/compute.ts +0 -273
  103. package/src/charts/range/index.ts +0 -82
  104. package/src/charts/range/labels.ts +0 -169
  105. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  106. package/src/charts/rect/index.ts +0 -127
  107. package/src/charts/registry.ts +0 -72
  108. package/src/charts/rule/index.ts +0 -139
  109. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  110. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  111. package/src/charts/scatter/compute.ts +0 -192
  112. package/src/charts/scatter/index.ts +0 -46
  113. package/src/charts/scatter/trendline.ts +0 -100
  114. package/src/charts/text/__tests__/compute.test.ts +0 -307
  115. package/src/charts/text/index.ts +0 -141
  116. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  117. package/src/charts/tick/index.ts +0 -99
  118. package/src/charts/utils.ts +0 -170
  119. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  120. package/src/charts/waffle/compute.ts +0 -197
  121. package/src/charts/waffle/index.ts +0 -31
  122. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  123. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  124. package/src/compile/__tests__/data-clip.test.ts +0 -127
  125. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  126. package/src/compile/__tests__/size-scale.test.ts +0 -108
  127. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  128. package/src/compile/color-scale-range.ts +0 -101
  129. package/src/compile/data-clip.ts +0 -48
  130. package/src/compile/fill-patterns.ts +0 -74
  131. package/src/compile/layer.ts +0 -630
  132. package/src/compile/size-scale.ts +0 -141
  133. package/src/compile/spec-sugar.ts +0 -767
  134. package/src/compile/watermark-obstacle.ts +0 -54
  135. package/src/compile/you-draw-it.ts +0 -123
  136. package/src/compile.ts +0 -1548
  137. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  138. package/src/compiler/__tests__/animation.test.ts +0 -177
  139. package/src/compiler/__tests__/compile.test.ts +0 -87
  140. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  141. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  142. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  143. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  144. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  153. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  154. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  155. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  156. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  158. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  159. package/src/compiler/__tests__/keys.test.ts +0 -83
  160. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  161. package/src/compiler/__tests__/normalize.test.ts +0 -399
  162. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  163. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  164. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  165. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  166. package/src/compiler/__tests__/validate.test.ts +0 -731
  167. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  168. package/src/compiler/a11y-warnings.ts +0 -110
  169. package/src/compiler/animation.ts +0 -142
  170. package/src/compiler/index.ts +0 -47
  171. package/src/compiler/keys.ts +0 -55
  172. package/src/compiler/normalize.ts +0 -734
  173. package/src/compiler/sparkline-defaults.ts +0 -138
  174. package/src/compiler/types.ts +0 -241
  175. package/src/compiler/validate.ts +0 -2093
  176. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  177. package/src/endpoint-labels/compute.ts +0 -621
  178. package/src/endpoint-labels/constants.ts +0 -54
  179. package/src/endpoint-labels/format.ts +0 -60
  180. package/src/endpoint-labels/predict.ts +0 -139
  181. package/src/format/__tests__/field-format.test.ts +0 -164
  182. package/src/format/field-format.ts +0 -27
  183. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  184. package/src/geo/compile-map.ts +0 -1376
  185. package/src/geo/join.ts +0 -115
  186. package/src/geo/projections.ts +0 -65
  187. package/src/geo/types.ts +0 -26
  188. package/src/graphs/__tests__/community.test.ts +0 -228
  189. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  190. package/src/graphs/__tests__/encoding.test.ts +0 -415
  191. package/src/graphs/community.ts +0 -92
  192. package/src/graphs/compile-graph.ts +0 -329
  193. package/src/graphs/encoding.ts +0 -356
  194. package/src/graphs/types.ts +0 -106
  195. package/src/index.ts +0 -177
  196. package/src/layout/__tests__/metrics.test.ts +0 -97
  197. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  198. package/src/layout/axes/rotation.ts +0 -152
  199. package/src/layout/axes/thinning.ts +0 -98
  200. package/src/layout/axes/ticks.ts +0 -409
  201. package/src/layout/axes.ts +0 -757
  202. package/src/layout/dimensions.ts +0 -912
  203. package/src/layout/facet.ts +0 -91
  204. package/src/layout/gridlines.ts +0 -84
  205. package/src/layout/metrics.ts +0 -132
  206. package/src/layout/plan.ts +0 -631
  207. package/src/layout/scales.ts +0 -1053
  208. package/src/layout/shared.ts +0 -44
  209. package/src/legend/__tests__/continuous.test.ts +0 -318
  210. package/src/legend/__tests__/redundancy.test.ts +0 -108
  211. package/src/legend/__tests__/size.test.ts +0 -348
  212. package/src/legend/__tests__/suppression.test.ts +0 -382
  213. package/src/legend/compute.ts +0 -748
  214. package/src/legend/continuous.ts +0 -327
  215. package/src/legend/size.ts +0 -407
  216. package/src/legend/suppression.ts +0 -226
  217. package/src/legend/wrap.ts +0 -117
  218. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  219. package/src/sankey/__tests__/layout.test.ts +0 -165
  220. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  221. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  222. package/src/sankey/compile-sankey.ts +0 -706
  223. package/src/sankey/layout.ts +0 -184
  224. package/src/sankey/types.ts +0 -41
  225. package/src/tables/__tests__/bar-column.test.ts +0 -147
  226. package/src/tables/__tests__/category-colors.test.ts +0 -230
  227. package/src/tables/__tests__/compile-table.test.ts +0 -236
  228. package/src/tables/__tests__/format-cells.test.ts +0 -143
  229. package/src/tables/__tests__/heatmap.test.ts +0 -148
  230. package/src/tables/__tests__/pagination.test.ts +0 -78
  231. package/src/tables/__tests__/search.test.ts +0 -94
  232. package/src/tables/__tests__/sort.test.ts +0 -107
  233. package/src/tables/__tests__/sparkline.test.ts +0 -122
  234. package/src/tables/bar-column.ts +0 -94
  235. package/src/tables/category-colors.ts +0 -78
  236. package/src/tables/compile-table.ts +0 -427
  237. package/src/tables/format-cells.ts +0 -115
  238. package/src/tables/heatmap.ts +0 -141
  239. package/src/tables/pagination.ts +0 -46
  240. package/src/tables/search.ts +0 -66
  241. package/src/tables/sort.ts +0 -69
  242. package/src/tables/sparkline.ts +0 -113
  243. package/src/tables/utils.ts +0 -16
  244. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  245. package/src/tilemap/compile-tilemap.ts +0 -755
  246. package/src/tilemap/layout.ts +0 -172
  247. package/src/tilemap/types.ts +0 -33
  248. package/src/tooltips/__tests__/compute.test.ts +0 -582
  249. package/src/tooltips/compute.ts +0 -700
  250. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  251. package/src/transforms/__tests__/bin.test.ts +0 -88
  252. package/src/transforms/__tests__/calculate.test.ts +0 -146
  253. package/src/transforms/__tests__/conditional.test.ts +0 -127
  254. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  255. package/src/transforms/__tests__/filter.test.ts +0 -59
  256. package/src/transforms/__tests__/fold.test.ts +0 -79
  257. package/src/transforms/__tests__/index.test.ts +0 -93
  258. package/src/transforms/__tests__/predicates.test.ts +0 -193
  259. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  260. package/src/transforms/__tests__/window.test.ts +0 -286
  261. package/src/transforms/aggregate.ts +0 -131
  262. package/src/transforms/bin.ts +0 -87
  263. package/src/transforms/calculate.ts +0 -60
  264. package/src/transforms/conditional.ts +0 -46
  265. package/src/transforms/filter.ts +0 -122
  266. package/src/transforms/fold.ts +0 -49
  267. package/src/transforms/index.ts +0 -60
  268. package/src/transforms/predicates.ts +0 -123
  269. package/src/transforms/timeunit.ts +0 -88
  270. package/src/transforms/window.ts +0 -185
@@ -1,748 +0,0 @@
1
- /**
2
- * Legend computation.
3
- *
4
- * Derives legend entries from the color encoding's unique values in data,
5
- * computes position based on layout strategy, and returns a LegendLayout
6
- * that dimensions.ts uses to reserve space in the chart area.
7
- *
8
- * The legend is computed early (before marks) so the chartArea accounts
9
- * for legend space. Entries come from data + encoding, not marks.
10
- *
11
- * Overflow protection: when there are too many entries for the available
12
- * space, entries are truncated and a "+N more" indicator is appended.
13
- *
14
- * The computation is split into two phases:
15
- * - `computeLegendContent`: everything width-dependent (entry derivation,
16
- * truncation, wrapping, row count, dimensions). No chart-area positioning.
17
- * - `placeLegend`: pure placement (computes `bounds` from chartArea).
18
- *
19
- * `computeLegend` is a thin compat wrapper that calls both.
20
- */
21
-
22
- import type {
23
- ContinuousLegendLayout,
24
- LayoutStrategy,
25
- LegendEntry,
26
- LegendEntryPosition,
27
- LegendLayout,
28
- Rect,
29
- ResolvedTheme,
30
- TextStyle,
31
- } from '@opendata-ai/openchart-core';
32
- import { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
33
-
34
- import { categoricalColorsForDomain } from '../compile/color-scale-range';
35
- import type { NormalizedChartSpec } from '../compiler/types';
36
- import type { MeasureFn } from '../layout/plan';
37
- import { applyCategoricalSort } from '../layout/scales';
38
- import {
39
- CONTINUOUS_LABEL_GAP,
40
- type ContinuousLegendContent,
41
- computeContinuousLegendContent,
42
- hasContinuousColorScale,
43
- } from './continuous';
44
- import { countColorSeries, resolveSuppression } from './suppression';
45
- import {
46
- ENTRY_GAP,
47
- ENTRY_GAP_COMPACT,
48
- legendGap,
49
- measureLegendWrap,
50
- SWATCH_GAP,
51
- SWATCH_SIZE,
52
- } from './wrap';
53
-
54
- // ---------------------------------------------------------------------------
55
- // Constants
56
- // ---------------------------------------------------------------------------
57
-
58
- const LEGEND_PADDING = 8;
59
- const LEGEND_RIGHT_WIDTH = 120;
60
-
61
- /** Max fraction of chart area height for right-positioned legends. */
62
- const RIGHT_LEGEND_MAX_HEIGHT_RATIO = 0.4;
63
-
64
- /** Max number of rows for top-positioned legends before truncation. */
65
- const TOP_LEGEND_MAX_ROWS = 2;
66
-
67
- // ---------------------------------------------------------------------------
68
- // LegendContent -- pre-computed content before placement
69
- // ---------------------------------------------------------------------------
70
-
71
- /** Pre-computed legend content produced before final layout. */
72
- export interface LegendContent {
73
- entries: LegendEntry[];
74
- position: 'top' | 'bottom' | 'right' | 'bottom-right' | 'inline';
75
- labelStyle: TextStyle;
76
- rowCount: number;
77
- totalWidth: number;
78
- height: number;
79
- entryGap: number;
80
- swatchSize: number;
81
- swatchGap: number;
82
- swatchChipFill: string;
83
- /** Width of the legend box (for right legends this is the column width). */
84
- legendWidth: number;
85
- /** User-provided legend offset, threaded through for placement. */
86
- offset?: { dx?: number; dy?: number };
87
- /** Relative entry positions (origin 0,0). Offset by bounds in placeLegend. */
88
- relativePositions?: LegendEntryPosition[];
89
- /** Row advance used to build entry positions. */
90
- rowHeight?: number;
91
- /**
92
- * Continuous legend payload (gradient bar / binned swatches). When set,
93
- * `entries` is empty and `height`/`legendWidth` describe the bar block.
94
- */
95
- continuous?: ContinuousLegendContent;
96
- }
97
-
98
- /**
99
- * True when the legend has something to render (categorical entries or a
100
- * continuous bar). Space-reservation predicates must use this instead of
101
- * checking `entries.length` directly.
102
- */
103
- export function hasLegendContent(content: LegendContent): boolean {
104
- return content.entries.length > 0 || content.continuous != null;
105
- }
106
-
107
- // ---------------------------------------------------------------------------
108
- // Helpers
109
- // ---------------------------------------------------------------------------
110
-
111
- /**
112
- * Resolve the constant fields every CategoricalLegendLayout return shares
113
- * (swatch geometry + chip fill). Pulled out so each early-return branch
114
- * doesn't re-spell the same five fields.
115
- */
116
- function categoricalDefaults(theme: ResolvedTheme): {
117
- swatchSize: number;
118
- swatchGap: number;
119
- entryGap: number;
120
- swatchChipFill: string;
121
- } {
122
- return {
123
- swatchSize: SWATCH_SIZE,
124
- swatchGap: SWATCH_GAP,
125
- entryGap: ENTRY_GAP,
126
- swatchChipFill: theme.colors.annotationFill,
127
- };
128
- }
129
-
130
- function resolveEntryPositions(
131
- relative: LegendEntryPosition[] | undefined,
132
- bounds: Rect,
133
- ): LegendEntryPosition[] | undefined {
134
- if (!relative) return undefined;
135
- return relative.map((p) => ({
136
- x: p.x + bounds.x,
137
- y: p.y + bounds.y,
138
- labelX: p.labelX + bounds.x,
139
- labelY: p.labelY + bounds.y,
140
- width: p.width,
141
- row: p.row,
142
- }));
143
- }
144
-
145
- /** Determine the swatch shape based on mark type. */
146
- function swatchShapeForType(markType: string): LegendEntry['shape'] {
147
- switch (markType) {
148
- case 'line':
149
- return 'line';
150
- case 'point':
151
- case 'circle':
152
- case 'lollipop':
153
- case 'beeswarm':
154
- case 'range':
155
- return 'circle';
156
- default:
157
- return 'square';
158
- }
159
- }
160
-
161
- /** Extract unique color values from data based on the color encoding. */
162
- function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): LegendEntry[] {
163
- const colorEnc = spec.encoding.color;
164
- if (!colorEnc) return [];
165
-
166
- // Conditional color definitions don't produce legend entries
167
- if ('condition' in colorEnc) return [];
168
-
169
- // Bare value defs (constant colors) don't produce legend entries either
170
- if (!('field' in colorEnc)) return [];
171
-
172
- // Sequential (quantitative) color doesn't produce discrete legend entries
173
- if (colorEnc.type === 'quantitative') return [];
174
-
175
- // Rows missing the color field (e.g. a sibling layer in a layered spec whose
176
- // marks don't participate in the color encoding) must not manufacture a legend
177
- // category. Without this guard, `String(undefined)` seeds a phantom "undefined"
178
- // entry and — with an explicit domain — appends it past the authored entries.
179
- const dataValues = [
180
- ...new Set(
181
- spec.data.filter((d) => d[colorEnc.field] != null).map((d) => String(d[colorEnc.field])),
182
- ),
183
- ];
184
- const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
185
- const explicitRange = colorEnc.scale?.range as string[] | undefined;
186
- const shape = swatchShapeForType(spec.markType);
187
-
188
- // Reproduce the ordinal color scale's domain so swatch colors index the
189
- // palette exactly like the mark colors do. `buildOrdinalColorScale` takes an
190
- // explicit domain verbatim, otherwise sorts the data values by `sort`.
191
- const scaleDomain = explicitDomain
192
- ? explicitDomain.map(String)
193
- : applyCategoricalSort(dataValues, colorEnc.sort);
194
-
195
- // Colors come from the same resolver the scale uses, so `highlight` muting
196
- // and the accent-neutral series strategy show up in the legend too. An
197
- // explicit range always wins (the scale honours it verbatim).
198
- const domainColors =
199
- explicitRange ?? categoricalColorsForDomain(scaleDomain, theme, spec.highlight);
200
-
201
- // Order legend entries by explicit domain when provided so the author
202
- // controls which entries render first (and which get truncated last when
203
- // symbolLimit applies). Without explicit domain, preserve data order.
204
- const uniqueValues = explicitDomain
205
- ? [
206
- ...explicitDomain.filter((v) => dataValues.includes(v)),
207
- ...dataValues.filter((v) => !explicitDomain.includes(v)),
208
- ]
209
- : dataValues;
210
-
211
- const excludeSet = new Set(spec.legend?.exclude ?? []);
212
- // Hidden series stay in the legend (dimmed) so the user can toggle them
213
- // back on. `active: false` is the renderer's signal to apply the dimmed
214
- // visual state.
215
- const hiddenSet = new Set(spec.hiddenSeries);
216
-
217
- return uniqueValues
218
- .map((value, i) => {
219
- // Look the value up in the scale domain so the color matches the mark
220
- // even when legend order differs from domain order. Values outside the
221
- // domain (a data value not in an explicit domain) fall back to position.
222
- const domainIdx = scaleDomain.indexOf(value);
223
- const colorIndex = domainIdx >= 0 ? domainIdx : i;
224
- return {
225
- label: value,
226
- color: domainColors[colorIndex % domainColors.length],
227
- shape,
228
- active: !hiddenSet.has(value),
229
- };
230
- })
231
- .filter((entry) => !excludeSet.has(entry.label));
232
- }
233
-
234
- /**
235
- * Truncate entries and add a "+N more" indicator if needed.
236
- */
237
- function truncateEntries(entries: LegendEntry[], maxCount: number): LegendEntry[] {
238
- if (maxCount >= entries.length || maxCount <= 0) return entries;
239
-
240
- const truncated = entries.slice(0, maxCount);
241
- const remaining = entries.length - maxCount;
242
- truncated.push({
243
- label: `+${remaining} more`,
244
- color: '#999999',
245
- shape: 'square',
246
- active: false,
247
- overflow: true,
248
- });
249
-
250
- return truncated;
251
- }
252
-
253
- /** Build an empty LegendContent for suppressed/hidden legends. */
254
- function emptyContent(position: LegendContent['position'], theme: ResolvedTheme): LegendContent {
255
- return {
256
- entries: [],
257
- position,
258
- labelStyle: {
259
- fontFamily: theme.fonts.family,
260
- fontSize: theme.fonts.sizes.small,
261
- fontWeight: theme.fonts.weights.normal,
262
- fill: theme.colors.text,
263
- lineHeight: 1.3,
264
- },
265
- rowCount: 0,
266
- totalWidth: 0,
267
- height: 0,
268
- legendWidth: 0,
269
- ...categoricalDefaults(theme),
270
- };
271
- }
272
-
273
- // ---------------------------------------------------------------------------
274
- // Public API
275
- // ---------------------------------------------------------------------------
276
-
277
- /**
278
- * Compute legend content (entries, dimensions, wrapping) without placement.
279
- *
280
- * This is the "measure" half of legend computation. It determines which
281
- * entries to show, how they wrap/truncate, and the resulting dimensions,
282
- * but does not compute a bounding box in chart coordinates.
283
- *
284
- * @param spec - Normalized chart spec.
285
- * @param strategy - Responsive layout strategy.
286
- * @param theme - Resolved theme.
287
- * @param availableWidth - Width constraint for layout (e.g. chartArea.width).
288
- * @param availableHeight - Height constraint for right-legend truncation.
289
- * @param watermark - Whether the brand watermark is shown (reserves space for bottom legends).
290
- * @param measure - Optional text measurement function; defaults to estimateTextWidth.
291
- * @returns LegendContent with entries, dimensions, but no bounds.
292
- */
293
- export function computeLegendContent(
294
- spec: NormalizedChartSpec,
295
- strategy: LayoutStrategy,
296
- theme: ResolvedTheme,
297
- availableWidth: number,
298
- availableHeight: number,
299
- watermark: boolean = true,
300
- measure?: MeasureFn,
301
- endpointLabelsDemoted?: boolean,
302
- ): LegendContent {
303
- const measureWidth = measure ?? estimateTextWidth;
304
-
305
- // Sparkline mode: legend hidden by default unless the user opted in. Color
306
- // scales still resolve normally (legend hidden != no colors), so multi-series
307
- // sparklines retain their categorical palette.
308
- const sparklineHidden = spec.display === 'sparkline' && !spec.userExplicit.legend;
309
-
310
- // Legend explicitly hidden via show: false, or height strategy says no legend
311
- if (sparklineHidden || spec.legend?.show === false || strategy.legendMaxHeight === 0) {
312
- return emptyContent('top', theme);
313
- }
314
-
315
- // Continuous color scale (quantitative color encoding): gradient bar or
316
- // binned swatch legend, on by default. Horizontal-bar geometry only, so the
317
- // position resolves to top (newsroom map-key convention) unless the user
318
- // asks for bottom; right-column positions can't fit the bar.
319
- if (hasContinuousColorScale(spec)) {
320
- const continuous = computeContinuousLegendContent(spec, theme, availableWidth);
321
- const continuousPosition = spec.legend?.position === 'bottom' ? 'bottom' : 'top';
322
- if (!continuous) {
323
- return emptyContent(continuousPosition, theme);
324
- }
325
- const labelStyle: TextStyle = {
326
- fontFamily: theme.fonts.family,
327
- fontSize: theme.fonts.sizes.small,
328
- fontWeight: theme.fonts.weights.normal,
329
- fill: theme.colors.text,
330
- lineHeight: 1.3,
331
- fontVariant: 'tabular-nums',
332
- };
333
- const labelRowHeight = Math.ceil(labelStyle.fontSize * labelStyle.lineHeight);
334
- const height = continuous.barHeight + CONTINUOUS_LABEL_GAP + labelRowHeight;
335
- return {
336
- entries: [],
337
- position: continuousPosition,
338
- labelStyle,
339
- rowCount: 1,
340
- totalWidth: continuous.barWidth,
341
- height,
342
- legendWidth: continuous.barWidth,
343
- offset: spec.legend?.offset,
344
- ...categoricalDefaults(theme),
345
- continuous,
346
- };
347
- }
348
-
349
- let entries = extractColorEntries(spec, theme);
350
-
351
- // Consult the shared suppression truth table so the legend, endpoint column,
352
- // and end-of-line labels stay in sync. The helper returns
353
- // `showTraditionalLegend: false` when the endpoint column auto-takes over
354
- // for >=2-series line/area charts (and the user hasn't forced the legend on).
355
- const seriesCount = countColorSeries(spec);
356
- const suppression = resolveSuppression(spec, {
357
- seriesCount,
358
- labelsHiddenByStrategy: strategy.labelMode === 'none',
359
- labelsDensityNone: spec.labels.density === 'none',
360
- endpointLabelsDemoted,
361
- });
362
- if (!suppression.showTraditionalLegend) {
363
- entries = [];
364
- }
365
-
366
- // Bar/column/lollipop/beeswarm redundancy rule: when color.field matches the
367
- // category axis field, the legend just repeats the axis labels. Hide it
368
- // unless the user explicitly configured the legend.
369
- const hasRedundantLegend =
370
- spec.markType === 'bar' || spec.markType === 'lollipop' || spec.markType === 'beeswarm';
371
- if (hasRedundantLegend && entries.length > 0 && !spec.userExplicit?.legend) {
372
- const colorEnc = spec.encoding.color;
373
- const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
374
- if (colorField) {
375
- const categoryField =
376
- spec.encoding.y?.type === 'quantitative' ? spec.encoding.x?.field : spec.encoding.y?.field;
377
- if (colorField === categoryField) {
378
- entries = [];
379
- }
380
- }
381
- }
382
-
383
- const labelStyle: TextStyle = {
384
- fontFamily: theme.fonts.family,
385
- fontSize: theme.fonts.sizes.small,
386
- fontWeight: theme.fonts.weights.normal,
387
- fill: theme.colors.text,
388
- lineHeight: 1.3,
389
- };
390
-
391
- // Resolve position: spec-level override wins, then responsive strategy.
392
- // 'top-left' is a map-only overlay position; charts fold it into 'top'.
393
- const specPosition = spec.legend?.position === 'top-left' ? 'top' : spec.legend?.position;
394
- const resolvedPosition = specPosition ?? (strategy.legendPosition === 'right' ? 'right' : 'top');
395
-
396
- // No entries = empty legend with no space
397
- if (entries.length === 0) {
398
- return emptyContent(resolvedPosition, theme);
399
- }
400
-
401
- const offset = spec.legend?.offset;
402
-
403
- if (resolvedPosition === 'right' || resolvedPosition === 'bottom-right') {
404
- // Right-positioned legend: vertical stack
405
- const maxLabelWidth = Math.max(
406
- ...entries.map((e) => measureWidth(e.label, labelStyle.fontSize, labelStyle.fontWeight)),
407
- );
408
- const legendWidth = Math.min(
409
- LEGEND_RIGHT_WIDTH,
410
- SWATCH_SIZE + SWATCH_GAP + maxLabelWidth + LEGEND_PADDING * 2,
411
- );
412
- const entryHeight = Math.max(SWATCH_SIZE, labelStyle.fontSize * labelStyle.lineHeight);
413
-
414
- // Apply max height ratio (default 40% of chart area, strategy can override)
415
- const maxHeightRatio =
416
- strategy.legendMaxHeight > 0 ? strategy.legendMaxHeight : RIGHT_LEGEND_MAX_HEIGHT_RATIO;
417
- const maxLegendHeight = availableHeight * maxHeightRatio;
418
-
419
- // Calculate how many entries fit
420
- const maxFromSpace = Math.max(
421
- 1,
422
- Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
423
- );
424
- // symbolLimit overrides the space-based limit when explicitly set (minimum 1)
425
- const maxEntries =
426
- spec.legend?.symbolLimit != null ? Math.max(1, spec.legend.symbolLimit) : maxFromSpace;
427
- if (entries.length > maxEntries) {
428
- entries = truncateEntries(entries, maxEntries);
429
- }
430
-
431
- const legendHeight =
432
- entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
433
- const clampedHeight = Math.min(legendHeight, availableHeight);
434
-
435
- const rightEntryGap = 4;
436
- const relativePositions: LegendEntryPosition[] = [];
437
- for (let i = 0; i < entries.length; i++) {
438
- const ex = 0;
439
- const ey = i * (entryHeight + rightEntryGap);
440
- const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
441
- relativePositions.push({
442
- x: ex,
443
- y: ey,
444
- labelX: ex + SWATCH_SIZE + SWATCH_GAP,
445
- labelY: ey + SWATCH_SIZE / 2,
446
- width: SWATCH_SIZE + SWATCH_GAP + labelWidth + rightEntryGap,
447
- row: i,
448
- });
449
- }
450
-
451
- return {
452
- entries,
453
- position: resolvedPosition,
454
- labelStyle,
455
- rowCount: entries.length,
456
- totalWidth: legendWidth,
457
- height: clampedHeight,
458
- legendWidth,
459
- offset,
460
- ...categoricalDefaults(theme),
461
- entryGap: rightEntryGap,
462
- relativePositions,
463
- rowHeight: entryHeight + rightEntryGap,
464
- };
465
- }
466
-
467
- // Top/bottom-positioned legend: horizontal flow with overflow protection.
468
- // Reserve space on the right for bottom legends so they don't overlap the brand
469
- // watermark. Top legends don't need this since the brand renders at the bottom.
470
- const reserveBrand = watermark && resolvedPosition === 'bottom';
471
- // Tighten gaps on narrow viewports so horizontal legends keep fitting on one row.
472
- const isCompact = availableWidth < COMPACT_WIDTH;
473
- const effectivePadding = isCompact ? 2 : LEGEND_PADDING;
474
- const effectiveEntryGap = isCompact ? ENTRY_GAP_COMPACT : ENTRY_GAP;
475
- const contentWidth =
476
- availableWidth - effectivePadding * 2 - (reserveBrand ? BRAND_RESERVE_WIDTH : 0);
477
-
478
- // Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
479
- if (spec.legend?.symbolLimit != null) {
480
- const limit = Math.max(1, spec.legend.symbolLimit);
481
- if (limit < entries.length) {
482
- entries = truncateEntries(entries, limit);
483
- }
484
- }
485
-
486
- // Resolve max rows: explicit maxRows wins, then columns-derived, then default.
487
- const maxRows =
488
- spec.legend?.maxRows != null
489
- ? Math.max(1, spec.legend.maxRows)
490
- : spec.legend?.columns != null
491
- ? Math.ceil(entries.length / spec.legend.columns)
492
- : TOP_LEGEND_MAX_ROWS;
493
- const { fittingCount } = measureLegendWrap(
494
- entries,
495
- contentWidth,
496
- labelStyle,
497
- maxRows,
498
- effectiveEntryGap,
499
- measureWidth,
500
- );
501
-
502
- if (fittingCount < entries.length) {
503
- entries = truncateEntries(entries, fittingCount);
504
- }
505
-
506
- const totalWidth = entries.reduce((sum, entry) => {
507
- const labelWidth = measureWidth(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
508
- return sum + SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap;
509
- }, 0);
510
-
511
- // Calculate actual row count for height (recompute after truncation).
512
- const finalWrap = measureLegendWrap(
513
- entries,
514
- contentWidth,
515
- labelStyle,
516
- undefined,
517
- effectiveEntryGap,
518
- measureWidth,
519
- );
520
- const { rowCount } = finalWrap;
521
-
522
- const rowHeight = SWATCH_SIZE + 4;
523
- const legendHeight = rowCount * rowHeight + effectivePadding * 2;
524
-
525
- // Build relative positions (origin 0,0), offset by bounds in placeLegend
526
- const relativePositions: LegendEntryPosition[] = [];
527
- for (let i = 0; i < entries.length; i++) {
528
- const placement = finalWrap.placements[i];
529
- if (!placement) break;
530
- const ex = placement.xOffset;
531
- const ey = placement.row * rowHeight;
532
- const labelWidth = measureWidth(entries[i].label, labelStyle.fontSize, labelStyle.fontWeight);
533
- relativePositions.push({
534
- x: ex,
535
- y: ey,
536
- labelX: ex + SWATCH_SIZE + SWATCH_GAP,
537
- labelY: ey + SWATCH_SIZE / 2,
538
- width: SWATCH_SIZE + SWATCH_GAP + labelWidth + effectiveEntryGap,
539
- row: placement.row,
540
- });
541
- }
542
-
543
- return {
544
- entries,
545
- position: resolvedPosition,
546
- labelStyle,
547
- rowCount,
548
- totalWidth,
549
- height: legendHeight,
550
- legendWidth: Math.min(totalWidth, contentWidth),
551
- offset,
552
- ...categoricalDefaults(theme),
553
- entryGap: effectiveEntryGap,
554
- relativePositions,
555
- rowHeight,
556
- };
557
- }
558
-
559
- /**
560
- * Place a pre-computed legend into chart coordinates.
561
- *
562
- * This is the "layout" half of legend computation. It takes a `LegendContent`
563
- * (from `computeLegendContent`) and produces a full `LegendLayout` with a
564
- * positioned bounding box.
565
- *
566
- * @param content - Pre-computed legend content.
567
- * @param chartArea - The final chart area (legend space already reserved in margins).
568
- * @param containerWidth - Full container width, used for responsive legend gap.
569
- * @param _theme - Resolved theme (reserved for future use).
570
- * @param xAxisHeight - Height of the x-axis, used for bottom legend positioning.
571
- * @param axisGapBelowLegend - The `effectiveAxisGap` the margin stack reserved
572
- * between a top legend and the chart area (inline y-tick labels draw in that
573
- * zone, above `chartArea.y`). The top legend is lifted above it so it
574
- * occupies its reserved slot instead of colliding with the topmost tick
575
- * label. Defaults to 0 so other callers/positions are unaffected.
576
- * @returns Full LegendLayout with bounds.
577
- */
578
- export function placeLegend(
579
- content: LegendContent,
580
- chartArea: Rect,
581
- containerWidth: number,
582
- _theme: ResolvedTheme,
583
- xAxisHeight: number,
584
- axisGapBelowLegend: number = 0,
585
- ): LegendLayout {
586
- const { position, entries, labelStyle, legendWidth, height, offset } = content;
587
-
588
- // Continuous legend: gradient bar / binned swatches. Placement mirrors the
589
- // categorical top/bottom math; the bar-relative geometry from the content
590
- // phase is offset into chart coordinates here.
591
- if (content.continuous) {
592
- const c = content.continuous;
593
- const cDx = offset?.dx ?? 0;
594
- const cDy = offset?.dy ?? 0;
595
- const cGap = legendGap(containerWidth);
596
- const boundsX = chartArea.x + cDx;
597
- const boundsY =
598
- position === 'bottom'
599
- ? chartArea.y + chartArea.height + xAxisHeight + cGap + cDy
600
- : chartArea.y - axisGapBelowLegend - cGap - height + cDy;
601
- const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
602
- const bar = { x: bounds.x, y: bounds.y, width: c.barWidth, height: c.barHeight };
603
- return {
604
- type: 'continuous' as const,
605
- mode: c.mode,
606
- position,
607
- bounds,
608
- labelStyle,
609
- bar,
610
- colorStops: c.colorStops,
611
- bins: c.bins.map((b) => ({ ...b, x: b.x + bar.x })),
612
- ticks: c.ticks.map((t) => ({ ...t, x: t.x + bar.x })),
613
- labelY: bar.y + bar.height + CONTINUOUS_LABEL_GAP + labelStyle.fontSize,
614
- } satisfies ContinuousLegendLayout;
615
- }
616
-
617
- // Empty legend = no bounds
618
- if (entries.length === 0) {
619
- return {
620
- type: 'categorical' as const,
621
- position,
622
- entries: [],
623
- bounds: { x: 0, y: 0, width: 0, height: 0 },
624
- labelStyle,
625
- swatchSize: content.swatchSize,
626
- swatchGap: content.swatchGap,
627
- entryGap: content.entryGap,
628
- swatchChipFill: content.swatchChipFill,
629
- };
630
- }
631
-
632
- const offsetDx = offset?.dx ?? 0;
633
- const offsetDy = offset?.dy ?? 0;
634
- const gap = legendGap(containerWidth);
635
-
636
- let boundsX: number;
637
- let boundsY: number;
638
-
639
- if (position === 'right' || position === 'bottom-right') {
640
- boundsX = chartArea.x + chartArea.width + 8 + offsetDx;
641
- boundsY =
642
- (position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
643
- offsetDy;
644
- } else if (position === 'bottom') {
645
- boundsX = chartArea.x + offsetDx;
646
- boundsY = chartArea.y + chartArea.height + xAxisHeight + gap + offsetDy;
647
- } else {
648
- // top
649
- boundsX = chartArea.x + offsetDx;
650
- boundsY = chartArea.y - axisGapBelowLegend - gap - height + offsetDy;
651
- }
652
-
653
- const bounds = { x: boundsX, y: boundsY, width: legendWidth, height };
654
-
655
- const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
656
-
657
- return {
658
- type: 'categorical' as const,
659
- position,
660
- entries,
661
- bounds,
662
- labelStyle,
663
- swatchSize: content.swatchSize,
664
- swatchGap: content.swatchGap,
665
- entryGap: content.entryGap,
666
- swatchChipFill: content.swatchChipFill,
667
- entryPositions,
668
- rowHeight: content.rowHeight,
669
- };
670
- }
671
-
672
- /**
673
- * Compute legend layout for a chart spec.
674
- *
675
- * Thin wrapper that calls `computeLegendContent` then `placeLegend`.
676
- * Existing callers are untouched.
677
- *
678
- * @param spec - Normalized chart spec.
679
- * @param strategy - Responsive layout strategy.
680
- * @param theme - Resolved theme.
681
- * @param chartArea - The available chart area (before legend space is reserved).
682
- * @returns LegendLayout with position, entries, and bounds.
683
- */
684
- export function computeLegend(
685
- spec: NormalizedChartSpec,
686
- strategy: LayoutStrategy,
687
- theme: ResolvedTheme,
688
- chartArea: Rect,
689
- watermark: boolean = true,
690
- ): LegendLayout {
691
- const content = computeLegendContent(
692
- spec,
693
- strategy,
694
- theme,
695
- chartArea.width,
696
- chartArea.height,
697
- watermark,
698
- );
699
- // Continuous legends share placeLegend's placement math.
700
- if (content.continuous) {
701
- return placeLegend(content, chartArea, chartArea.width, theme, 0);
702
- }
703
- const { position, entries, labelStyle, legendWidth, height, offset: off } = content;
704
- const offsetDx = off?.dx ?? 0;
705
- const offsetDy = off?.dy ?? 0;
706
- let boundsX = chartArea.x + offsetDx;
707
- let boundsY = chartArea.y + offsetDy;
708
- const boundsW = legendWidth;
709
- if (entries.length === 0) {
710
- return {
711
- type: 'categorical' as const,
712
- position,
713
- entries: [],
714
- bounds: { x: 0, y: 0, width: 0, height: 0 },
715
- labelStyle,
716
- swatchSize: content.swatchSize,
717
- swatchGap: content.swatchGap,
718
- entryGap: content.entryGap,
719
- swatchChipFill: content.swatchChipFill,
720
- };
721
- }
722
- if (position === 'right' || position === 'bottom-right') {
723
- boundsX = chartArea.x + chartArea.width - legendWidth + offsetDx;
724
- boundsY =
725
- (position === 'bottom-right' ? chartArea.y + chartArea.height - height : chartArea.y) +
726
- offsetDy;
727
- } else if (position === 'bottom') {
728
- boundsY = chartArea.y + chartArea.height - height + offsetDy;
729
- }
730
-
731
- const bounds = { x: boundsX, y: boundsY, width: boundsW, height };
732
-
733
- const entryPositions = resolveEntryPositions(content.relativePositions, bounds);
734
-
735
- return {
736
- type: 'categorical' as const,
737
- position,
738
- entries,
739
- bounds,
740
- labelStyle,
741
- swatchSize: content.swatchSize,
742
- swatchGap: content.swatchGap,
743
- entryGap: content.entryGap,
744
- swatchChipFill: content.swatchChipFill,
745
- entryPositions,
746
- rowHeight: content.rowHeight,
747
- };
748
- }