@opendata-ai/openchart-engine 8.0.0-rc.9 → 8.0.0

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 (271) hide show
  1. package/dist/index.d.ts +149 -12
  2. package/dist/index.js +804 -155
  3. package/dist/index.js.map +1 -1
  4. package/package.json +3 -4
  5. package/src/__test-fixtures__/specs.ts +0 -182
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +0 -2401
  7. package/src/__tests__/annotation-behavior.test.ts +0 -852
  8. package/src/__tests__/axes.test.ts +0 -1579
  9. package/src/__tests__/compile-animation.test.ts +0 -161
  10. package/src/__tests__/compile-chart.test.ts +0 -1303
  11. package/src/__tests__/compile-footnotes.test.ts +0 -277
  12. package/src/__tests__/compile-layer.test.ts +0 -969
  13. package/src/__tests__/compile-snapshot.test.ts +0 -156
  14. package/src/__tests__/compound-labels.test.ts +0 -147
  15. package/src/__tests__/dimensions.test.ts +0 -918
  16. package/src/__tests__/encoding-sugar.test.ts +0 -390
  17. package/src/__tests__/facet.test.ts +0 -215
  18. package/src/__tests__/helpers/invariants.ts +0 -299
  19. package/src/__tests__/known-bugs.test.ts +0 -566
  20. package/src/__tests__/layout-fuzz.test.ts +0 -358
  21. package/src/__tests__/layout-invariants.test.ts +0 -535
  22. package/src/__tests__/legend.test.ts +0 -721
  23. package/src/__tests__/mark-keys.test.ts +0 -294
  24. package/src/__tests__/ranking-recipes.test.ts +0 -260
  25. package/src/__tests__/rotation.test.ts +0 -83
  26. package/src/__tests__/scales.test.ts +0 -323
  27. package/src/__tests__/series-search.test.ts +0 -92
  28. package/src/__tests__/spec-sugar.test.ts +0 -801
  29. package/src/__tests__/you-draw-it.test.ts +0 -160
  30. package/src/annotations/__tests__/compute.test.ts +0 -1672
  31. package/src/annotations/__tests__/geometry.test.ts +0 -66
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +0 -256
  33. package/src/annotations/__tests__/placement-invariants.test.ts +0 -345
  34. package/src/annotations/__tests__/placement.test.ts +0 -316
  35. package/src/annotations/__tests__/resolve-text.test.ts +0 -788
  36. package/src/annotations/__tests__/rich-text.test.ts +0 -91
  37. package/src/annotations/__tests__/thinning.test.ts +0 -285
  38. package/src/annotations/collisions.ts +0 -326
  39. package/src/annotations/compute.ts +0 -333
  40. package/src/annotations/constants.ts +0 -162
  41. package/src/annotations/geometry.ts +0 -376
  42. package/src/annotations/move.ts +0 -135
  43. package/src/annotations/placement.ts +0 -636
  44. package/src/annotations/position.ts +0 -131
  45. package/src/annotations/resolve-range.ts +0 -117
  46. package/src/annotations/resolve-refline.ts +0 -157
  47. package/src/annotations/resolve-text.ts +0 -545
  48. package/src/annotations/rich-text.ts +0 -65
  49. package/src/annotations/thinning.ts +0 -190
  50. package/src/barlist/__tests__/compile-barlist.test.ts +0 -200
  51. package/src/barlist/compile-barlist.ts +0 -391
  52. package/src/barlist/types.ts +0 -28
  53. package/src/charts/__tests__/post-process.test.ts +0 -237
  54. package/src/charts/__tests__/registry.test.ts +0 -116
  55. package/src/charts/__tests__/utils.test.ts +0 -195
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +0 -32
  57. package/src/charts/_shared/density-filter.ts +0 -26
  58. package/src/charts/_shared/format-label-value.ts +0 -16
  59. package/src/charts/bar/__tests__/compute.test.ts +0 -934
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +0 -127
  61. package/src/charts/bar/__tests__/labels.test.ts +0 -292
  62. package/src/charts/bar/compute.ts +0 -539
  63. package/src/charts/bar/index.ts +0 -60
  64. package/src/charts/bar/labels.ts +0 -311
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +0 -280
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +0 -148
  67. package/src/charts/beeswarm/compute.ts +0 -203
  68. package/src/charts/beeswarm/dodge.ts +0 -155
  69. package/src/charts/beeswarm/index.ts +0 -32
  70. package/src/charts/builtin.ts +0 -75
  71. package/src/charts/calendar/__tests__/compute.test.ts +0 -417
  72. package/src/charts/calendar/compute.ts +0 -319
  73. package/src/charts/calendar/index.ts +0 -32
  74. package/src/charts/column/__tests__/compute.test.ts +0 -540
  75. package/src/charts/column/__tests__/labels.test.ts +0 -179
  76. package/src/charts/column/compute.ts +0 -493
  77. package/src/charts/column/index.ts +0 -53
  78. package/src/charts/column/labels.ts +0 -229
  79. package/src/charts/dot/__tests__/compute.test.ts +0 -380
  80. package/src/charts/dot/__tests__/labels.test.ts +0 -98
  81. package/src/charts/dot/compute.ts +0 -285
  82. package/src/charts/dot/index.ts +0 -62
  83. package/src/charts/dot/labels.ts +0 -121
  84. package/src/charts/line/__tests__/compute.test.ts +0 -1194
  85. package/src/charts/line/__tests__/curves.test.ts +0 -51
  86. package/src/charts/line/__tests__/labels.test.ts +0 -94
  87. package/src/charts/line/__tests__/mark-options.test.ts +0 -175
  88. package/src/charts/line/area.ts +0 -484
  89. package/src/charts/line/compute.ts +0 -356
  90. package/src/charts/line/curves.ts +0 -85
  91. package/src/charts/line/index.ts +0 -176
  92. package/src/charts/line/labels.ts +0 -177
  93. package/src/charts/parliament/__tests__/compute.test.ts +0 -411
  94. package/src/charts/parliament/compute.ts +0 -313
  95. package/src/charts/parliament/index.ts +0 -32
  96. package/src/charts/pie/__tests__/compute.test.ts +0 -384
  97. package/src/charts/pie/__tests__/labels.test.ts +0 -132
  98. package/src/charts/pie/compute.ts +0 -324
  99. package/src/charts/pie/index.ts +0 -49
  100. package/src/charts/pie/labels.ts +0 -143
  101. package/src/charts/post-process.ts +0 -229
  102. package/src/charts/range/__tests__/compute.test.ts +0 -486
  103. package/src/charts/range/compute.ts +0 -273
  104. package/src/charts/range/index.ts +0 -82
  105. package/src/charts/range/labels.ts +0 -169
  106. package/src/charts/rect/__tests__/compute.test.ts +0 -120
  107. package/src/charts/rect/index.ts +0 -127
  108. package/src/charts/registry.ts +0 -72
  109. package/src/charts/rule/index.ts +0 -139
  110. package/src/charts/scatter/__tests__/compute.test.ts +0 -488
  111. package/src/charts/scatter/__tests__/trendline.test.ts +0 -191
  112. package/src/charts/scatter/compute.ts +0 -192
  113. package/src/charts/scatter/index.ts +0 -46
  114. package/src/charts/scatter/trendline.ts +0 -100
  115. package/src/charts/text/__tests__/compute.test.ts +0 -307
  116. package/src/charts/text/index.ts +0 -141
  117. package/src/charts/tick/__tests__/compute.test.ts +0 -75
  118. package/src/charts/tick/index.ts +0 -99
  119. package/src/charts/utils.ts +0 -170
  120. package/src/charts/waffle/__tests__/compute.test.ts +0 -305
  121. package/src/charts/waffle/compute.ts +0 -197
  122. package/src/charts/waffle/index.ts +0 -31
  123. package/src/compile/__tests__/color-scale-range.test.ts +0 -158
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +0 -89
  125. package/src/compile/__tests__/data-clip.test.ts +0 -127
  126. package/src/compile/__tests__/fill-patterns.test.ts +0 -154
  127. package/src/compile/__tests__/size-scale.test.ts +0 -108
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +0 -93
  129. package/src/compile/color-scale-range.ts +0 -101
  130. package/src/compile/data-clip.ts +0 -48
  131. package/src/compile/fill-patterns.ts +0 -74
  132. package/src/compile/layer.ts +0 -630
  133. package/src/compile/size-scale.ts +0 -141
  134. package/src/compile/spec-sugar.ts +0 -758
  135. package/src/compile/watermark-obstacle.ts +0 -54
  136. package/src/compile/you-draw-it.ts +0 -123
  137. package/src/compile.ts +0 -1503
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +0 -148
  139. package/src/compiler/__tests__/animation.test.ts +0 -177
  140. package/src/compiler/__tests__/compile.test.ts +0 -87
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +0 -19
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +0 -20
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +0 -22
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +0 -19
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +0 -14
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +0 -34
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +0 -22
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +0 -41
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +0 -38
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +0 -28
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +0 -28
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +0 -30
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +0 -29
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +0 -32
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +0 -25
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +0 -19
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +0 -19
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +0 -21
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +0 -23
  160. package/src/compiler/__tests__/keys.test.ts +0 -83
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +0 -103
  162. package/src/compiler/__tests__/normalize.test.ts +0 -399
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +0 -153
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +0 -170
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -251
  167. package/src/compiler/__tests__/validate.test.ts +0 -629
  168. package/src/compiler/__tests__/vl-parity.test.ts +0 -68
  169. package/src/compiler/a11y-warnings.ts +0 -110
  170. package/src/compiler/animation.ts +0 -142
  171. package/src/compiler/index.ts +0 -47
  172. package/src/compiler/keys.ts +0 -55
  173. package/src/compiler/normalize.ts +0 -734
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -2041
  177. package/src/endpoint-labels/__tests__/compute.test.ts +0 -587
  178. package/src/endpoint-labels/compute.ts +0 -621
  179. package/src/endpoint-labels/constants.ts +0 -54
  180. package/src/endpoint-labels/format.ts +0 -60
  181. package/src/endpoint-labels/predict.ts +0 -139
  182. package/src/format/__tests__/field-format.test.ts +0 -164
  183. package/src/format/field-format.ts +0 -27
  184. package/src/geo/__tests__/compile-map.test.ts +0 -1128
  185. package/src/geo/compile-map.ts +0 -1376
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -65
  188. package/src/geo/types.ts +0 -26
  189. package/src/graphs/__tests__/community.test.ts +0 -228
  190. package/src/graphs/__tests__/compile-graph.test.ts +0 -396
  191. package/src/graphs/__tests__/encoding.test.ts +0 -415
  192. package/src/graphs/community.ts +0 -92
  193. package/src/graphs/compile-graph.ts +0 -329
  194. package/src/graphs/encoding.ts +0 -356
  195. package/src/graphs/types.ts +0 -106
  196. package/src/index.ts +0 -177
  197. package/src/layout/__tests__/metrics.test.ts +0 -97
  198. package/src/layout/__tests__/scales-highlight.test.ts +0 -192
  199. package/src/layout/axes/rotation.ts +0 -152
  200. package/src/layout/axes/thinning.ts +0 -98
  201. package/src/layout/axes/ticks.ts +0 -409
  202. package/src/layout/axes.ts +0 -757
  203. package/src/layout/dimensions.ts +0 -912
  204. package/src/layout/facet.ts +0 -91
  205. package/src/layout/gridlines.ts +0 -84
  206. package/src/layout/metrics.ts +0 -132
  207. package/src/layout/plan.ts +0 -631
  208. package/src/layout/scales.ts +0 -1053
  209. package/src/layout/shared.ts +0 -44
  210. package/src/legend/__tests__/continuous.test.ts +0 -318
  211. package/src/legend/__tests__/redundancy.test.ts +0 -108
  212. package/src/legend/__tests__/size.test.ts +0 -348
  213. package/src/legend/__tests__/suppression.test.ts +0 -382
  214. package/src/legend/compute.ts +0 -748
  215. package/src/legend/continuous.ts +0 -327
  216. package/src/legend/size.ts +0 -407
  217. package/src/legend/suppression.ts +0 -226
  218. package/src/legend/wrap.ts +0 -117
  219. package/src/sankey/__tests__/compile-sankey.test.ts +0 -458
  220. package/src/sankey/__tests__/layout.test.ts +0 -165
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +0 -114
  222. package/src/sankey/__tests__/node-sort.test.ts +0 -45
  223. package/src/sankey/compile-sankey.ts +0 -706
  224. package/src/sankey/layout.ts +0 -184
  225. package/src/sankey/types.ts +0 -41
  226. package/src/tables/__tests__/bar-column.test.ts +0 -147
  227. package/src/tables/__tests__/category-colors.test.ts +0 -230
  228. package/src/tables/__tests__/compile-table.test.ts +0 -236
  229. package/src/tables/__tests__/format-cells.test.ts +0 -143
  230. package/src/tables/__tests__/heatmap.test.ts +0 -148
  231. package/src/tables/__tests__/pagination.test.ts +0 -78
  232. package/src/tables/__tests__/search.test.ts +0 -94
  233. package/src/tables/__tests__/sort.test.ts +0 -107
  234. package/src/tables/__tests__/sparkline.test.ts +0 -122
  235. package/src/tables/bar-column.ts +0 -94
  236. package/src/tables/category-colors.ts +0 -78
  237. package/src/tables/compile-table.ts +0 -427
  238. package/src/tables/format-cells.ts +0 -115
  239. package/src/tables/heatmap.ts +0 -141
  240. package/src/tables/pagination.ts +0 -46
  241. package/src/tables/search.ts +0 -66
  242. package/src/tables/sort.ts +0 -69
  243. package/src/tables/sparkline.ts +0 -113
  244. package/src/tables/utils.ts +0 -16
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +0 -576
  246. package/src/tilemap/compile-tilemap.ts +0 -755
  247. package/src/tilemap/layout.ts +0 -172
  248. package/src/tilemap/types.ts +0 -33
  249. package/src/tooltips/__tests__/compute.test.ts +0 -582
  250. package/src/tooltips/compute.ts +0 -700
  251. package/src/transforms/__tests__/aggregate.test.ts +0 -159
  252. package/src/transforms/__tests__/bin.test.ts +0 -88
  253. package/src/transforms/__tests__/calculate.test.ts +0 -146
  254. package/src/transforms/__tests__/conditional.test.ts +0 -127
  255. package/src/transforms/__tests__/filter-relative.test.ts +0 -202
  256. package/src/transforms/__tests__/filter.test.ts +0 -59
  257. package/src/transforms/__tests__/fold.test.ts +0 -79
  258. package/src/transforms/__tests__/index.test.ts +0 -93
  259. package/src/transforms/__tests__/predicates.test.ts +0 -193
  260. package/src/transforms/__tests__/timeunit.test.ts +0 -129
  261. package/src/transforms/__tests__/window.test.ts +0 -286
  262. package/src/transforms/aggregate.ts +0 -131
  263. package/src/transforms/bin.ts +0 -87
  264. package/src/transforms/calculate.ts +0 -60
  265. package/src/transforms/conditional.ts +0 -46
  266. package/src/transforms/filter.ts +0 -122
  267. package/src/transforms/fold.ts +0 -49
  268. package/src/transforms/index.ts +0 -60
  269. package/src/transforms/predicates.ts +0 -123
  270. package/src/transforms/timeunit.ts +0 -88
  271. 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
- }