@opendata-ai/openchart-engine 8.0.0-rc.12 → 8.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (271) hide show
  1. package/dist/index.d.ts +0 -7
  2. package/dist/index.js +161 -579
  3. package/dist/index.js.map +1 -1
  4. package/package.json +4 -3
  5. package/src/__test-fixtures__/specs.ts +182 -0
  6. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +2339 -0
  7. package/src/__tests__/annotation-behavior.test.ts +721 -0
  8. package/src/__tests__/axes.test.ts +1579 -0
  9. package/src/__tests__/compile-animation.test.ts +161 -0
  10. package/src/__tests__/compile-chart.test.ts +1303 -0
  11. package/src/__tests__/compile-footnotes.test.ts +277 -0
  12. package/src/__tests__/compile-layer.test.ts +969 -0
  13. package/src/__tests__/compile-snapshot.test.ts +156 -0
  14. package/src/__tests__/compound-labels.test.ts +147 -0
  15. package/src/__tests__/dimensions.test.ts +918 -0
  16. package/src/__tests__/encoding-sugar.test.ts +390 -0
  17. package/src/__tests__/facet.test.ts +215 -0
  18. package/src/__tests__/helpers/invariants.ts +299 -0
  19. package/src/__tests__/known-bugs.test.ts +566 -0
  20. package/src/__tests__/layout-fuzz.test.ts +358 -0
  21. package/src/__tests__/layout-invariants.test.ts +535 -0
  22. package/src/__tests__/legend.test.ts +721 -0
  23. package/src/__tests__/mark-keys.test.ts +294 -0
  24. package/src/__tests__/ranking-recipes.test.ts +260 -0
  25. package/src/__tests__/rotation.test.ts +83 -0
  26. package/src/__tests__/scales.test.ts +323 -0
  27. package/src/__tests__/series-search.test.ts +92 -0
  28. package/src/__tests__/spec-sugar.test.ts +801 -0
  29. package/src/__tests__/you-draw-it.test.ts +160 -0
  30. package/src/annotations/__tests__/compute.test.ts +1672 -0
  31. package/src/annotations/__tests__/geometry.test.ts +66 -0
  32. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  33. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  34. package/src/annotations/__tests__/placement.test.ts +316 -0
  35. package/src/annotations/__tests__/resolve-text.test.ts +788 -0
  36. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  37. package/src/annotations/__tests__/thinning.test.ts +285 -0
  38. package/src/annotations/collisions.ts +326 -0
  39. package/src/annotations/compute.ts +333 -0
  40. package/src/annotations/constants.ts +162 -0
  41. package/src/annotations/geometry.ts +370 -0
  42. package/src/annotations/move.ts +135 -0
  43. package/src/annotations/placement.ts +636 -0
  44. package/src/annotations/position.ts +131 -0
  45. package/src/annotations/resolve-range.ts +117 -0
  46. package/src/annotations/resolve-refline.ts +157 -0
  47. package/src/annotations/resolve-text.ts +539 -0
  48. package/src/annotations/rich-text.ts +65 -0
  49. package/src/annotations/thinning.ts +190 -0
  50. package/src/barlist/__tests__/compile-barlist.test.ts +200 -0
  51. package/src/barlist/compile-barlist.ts +391 -0
  52. package/src/barlist/types.ts +28 -0
  53. package/src/charts/__tests__/post-process.test.ts +237 -0
  54. package/src/charts/__tests__/registry.test.ts +116 -0
  55. package/src/charts/__tests__/utils.test.ts +195 -0
  56. package/src/charts/_shared/__tests__/density-filter.test.ts +32 -0
  57. package/src/charts/_shared/density-filter.ts +26 -0
  58. package/src/charts/_shared/format-label-value.ts +16 -0
  59. package/src/charts/bar/__tests__/compute.test.ts +934 -0
  60. package/src/charts/bar/__tests__/gradient-orient.test.ts +127 -0
  61. package/src/charts/bar/__tests__/labels.test.ts +292 -0
  62. package/src/charts/bar/compute.ts +539 -0
  63. package/src/charts/bar/index.ts +60 -0
  64. package/src/charts/bar/labels.ts +311 -0
  65. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  66. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  67. package/src/charts/beeswarm/compute.ts +203 -0
  68. package/src/charts/beeswarm/dodge.ts +155 -0
  69. package/src/charts/beeswarm/index.ts +32 -0
  70. package/src/charts/builtin.ts +75 -0
  71. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  72. package/src/charts/calendar/compute.ts +319 -0
  73. package/src/charts/calendar/index.ts +32 -0
  74. package/src/charts/column/__tests__/compute.test.ts +540 -0
  75. package/src/charts/column/__tests__/labels.test.ts +179 -0
  76. package/src/charts/column/compute.ts +493 -0
  77. package/src/charts/column/index.ts +53 -0
  78. package/src/charts/column/labels.ts +229 -0
  79. package/src/charts/dot/__tests__/compute.test.ts +380 -0
  80. package/src/charts/dot/__tests__/labels.test.ts +98 -0
  81. package/src/charts/dot/compute.ts +285 -0
  82. package/src/charts/dot/index.ts +62 -0
  83. package/src/charts/dot/labels.ts +121 -0
  84. package/src/charts/line/__tests__/compute.test.ts +1194 -0
  85. package/src/charts/line/__tests__/curves.test.ts +51 -0
  86. package/src/charts/line/__tests__/labels.test.ts +94 -0
  87. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  88. package/src/charts/line/area.ts +484 -0
  89. package/src/charts/line/compute.ts +356 -0
  90. package/src/charts/line/curves.ts +85 -0
  91. package/src/charts/line/index.ts +176 -0
  92. package/src/charts/line/labels.ts +177 -0
  93. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  94. package/src/charts/parliament/compute.ts +313 -0
  95. package/src/charts/parliament/index.ts +32 -0
  96. package/src/charts/pie/__tests__/compute.test.ts +384 -0
  97. package/src/charts/pie/__tests__/labels.test.ts +132 -0
  98. package/src/charts/pie/compute.ts +324 -0
  99. package/src/charts/pie/index.ts +49 -0
  100. package/src/charts/pie/labels.ts +143 -0
  101. package/src/charts/post-process.ts +229 -0
  102. package/src/charts/range/__tests__/compute.test.ts +486 -0
  103. package/src/charts/range/compute.ts +273 -0
  104. package/src/charts/range/index.ts +82 -0
  105. package/src/charts/range/labels.ts +169 -0
  106. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  107. package/src/charts/rect/index.ts +127 -0
  108. package/src/charts/registry.ts +72 -0
  109. package/src/charts/rule/index.ts +139 -0
  110. package/src/charts/scatter/__tests__/compute.test.ts +488 -0
  111. package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
  112. package/src/charts/scatter/compute.ts +192 -0
  113. package/src/charts/scatter/index.ts +46 -0
  114. package/src/charts/scatter/trendline.ts +100 -0
  115. package/src/charts/text/__tests__/compute.test.ts +307 -0
  116. package/src/charts/text/index.ts +141 -0
  117. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  118. package/src/charts/tick/index.ts +99 -0
  119. package/src/charts/utils.ts +170 -0
  120. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  121. package/src/charts/waffle/compute.ts +197 -0
  122. package/src/charts/waffle/index.ts +31 -0
  123. package/src/compile/__tests__/color-scale-range.test.ts +158 -0
  124. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  125. package/src/compile/__tests__/data-clip.test.ts +127 -0
  126. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  127. package/src/compile/__tests__/size-scale.test.ts +108 -0
  128. package/src/compile/__tests__/watermark-obstacle.test.ts +93 -0
  129. package/src/compile/color-scale-range.ts +101 -0
  130. package/src/compile/data-clip.ts +48 -0
  131. package/src/compile/fill-patterns.ts +74 -0
  132. package/src/compile/layer.ts +626 -0
  133. package/src/compile/size-scale.ts +141 -0
  134. package/src/compile/spec-sugar.ts +758 -0
  135. package/src/compile/watermark-obstacle.ts +54 -0
  136. package/src/compile/you-draw-it.ts +123 -0
  137. package/src/compile.ts +1450 -0
  138. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  139. package/src/compiler/__tests__/animation.test.ts +177 -0
  140. package/src/compiler/__tests__/compile.test.ts +87 -0
  141. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  142. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  143. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  144. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  145. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  146. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  147. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  148. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  149. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  150. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  151. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  152. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  153. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  154. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  155. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  156. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  157. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  158. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  159. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  160. package/src/compiler/__tests__/keys.test.ts +83 -0
  161. package/src/compiler/__tests__/normalize-highlight.test.ts +103 -0
  162. package/src/compiler/__tests__/normalize.test.ts +399 -0
  163. package/src/compiler/__tests__/sparkline-defaults.test.ts +153 -0
  164. package/src/compiler/__tests__/validate-did-you-mean.test.ts +86 -0
  165. package/src/compiler/__tests__/validate-highlight.test.ts +112 -0
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +89 -0
  167. package/src/compiler/__tests__/validate.test.ts +629 -0
  168. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  169. package/src/compiler/a11y-warnings.ts +110 -0
  170. package/src/compiler/animation.ts +142 -0
  171. package/src/compiler/index.ts +47 -0
  172. package/src/compiler/keys.ts +55 -0
  173. package/src/compiler/normalize.ts +734 -0
  174. package/src/compiler/sparkline-defaults.ts +138 -0
  175. package/src/compiler/types.ts +241 -0
  176. package/src/compiler/validate.ts +1871 -0
  177. package/src/endpoint-labels/__tests__/compute.test.ts +587 -0
  178. package/src/endpoint-labels/compute.ts +621 -0
  179. package/src/endpoint-labels/constants.ts +54 -0
  180. package/src/endpoint-labels/format.ts +60 -0
  181. package/src/endpoint-labels/predict.ts +139 -0
  182. package/src/format/__tests__/field-format.test.ts +164 -0
  183. package/src/format/field-format.ts +27 -0
  184. package/src/geo/__tests__/compile-map.test.ts +832 -0
  185. package/src/geo/compile-map.ts +1243 -0
  186. package/src/geo/join.ts +115 -0
  187. package/src/geo/projections.ts +45 -0
  188. package/src/geo/types.ts +26 -0
  189. package/src/graphs/__tests__/community.test.ts +228 -0
  190. package/src/graphs/__tests__/compile-graph.test.ts +396 -0
  191. package/src/graphs/__tests__/encoding.test.ts +415 -0
  192. package/src/graphs/community.ts +92 -0
  193. package/src/graphs/compile-graph.ts +329 -0
  194. package/src/graphs/encoding.ts +356 -0
  195. package/src/graphs/types.ts +106 -0
  196. package/src/index.ts +177 -0
  197. package/src/layout/__tests__/metrics.test.ts +97 -0
  198. package/src/layout/__tests__/scales-highlight.test.ts +192 -0
  199. package/src/layout/axes/rotation.ts +152 -0
  200. package/src/layout/axes/thinning.ts +98 -0
  201. package/src/layout/axes/ticks.ts +409 -0
  202. package/src/layout/axes.ts +757 -0
  203. package/src/layout/dimensions.ts +912 -0
  204. package/src/layout/facet.ts +91 -0
  205. package/src/layout/gridlines.ts +84 -0
  206. package/src/layout/metrics.ts +132 -0
  207. package/src/layout/plan.ts +631 -0
  208. package/src/layout/scales.ts +1053 -0
  209. package/src/layout/shared.ts +44 -0
  210. package/src/legend/__tests__/continuous.test.ts +318 -0
  211. package/src/legend/__tests__/redundancy.test.ts +108 -0
  212. package/src/legend/__tests__/size.test.ts +348 -0
  213. package/src/legend/__tests__/suppression.test.ts +382 -0
  214. package/src/legend/compute.ts +747 -0
  215. package/src/legend/continuous.ts +327 -0
  216. package/src/legend/size.ts +407 -0
  217. package/src/legend/suppression.ts +226 -0
  218. package/src/legend/wrap.ts +117 -0
  219. package/src/sankey/__tests__/compile-sankey.test.ts +458 -0
  220. package/src/sankey/__tests__/layout.test.ts +165 -0
  221. package/src/sankey/__tests__/node-label-wrap.test.ts +114 -0
  222. package/src/sankey/__tests__/node-sort.test.ts +45 -0
  223. package/src/sankey/compile-sankey.ts +706 -0
  224. package/src/sankey/layout.ts +184 -0
  225. package/src/sankey/types.ts +41 -0
  226. package/src/tables/__tests__/bar-column.test.ts +147 -0
  227. package/src/tables/__tests__/category-colors.test.ts +230 -0
  228. package/src/tables/__tests__/compile-table.test.ts +236 -0
  229. package/src/tables/__tests__/format-cells.test.ts +143 -0
  230. package/src/tables/__tests__/heatmap.test.ts +148 -0
  231. package/src/tables/__tests__/pagination.test.ts +78 -0
  232. package/src/tables/__tests__/search.test.ts +94 -0
  233. package/src/tables/__tests__/sort.test.ts +107 -0
  234. package/src/tables/__tests__/sparkline.test.ts +122 -0
  235. package/src/tables/bar-column.ts +94 -0
  236. package/src/tables/category-colors.ts +78 -0
  237. package/src/tables/compile-table.ts +427 -0
  238. package/src/tables/format-cells.ts +115 -0
  239. package/src/tables/heatmap.ts +141 -0
  240. package/src/tables/pagination.ts +46 -0
  241. package/src/tables/search.ts +66 -0
  242. package/src/tables/sort.ts +69 -0
  243. package/src/tables/sparkline.ts +113 -0
  244. package/src/tables/utils.ts +16 -0
  245. package/src/tilemap/__tests__/compile-tilemap.test.ts +576 -0
  246. package/src/tilemap/compile-tilemap.ts +755 -0
  247. package/src/tilemap/layout.ts +172 -0
  248. package/src/tilemap/types.ts +33 -0
  249. package/src/tooltips/__tests__/compute.test.ts +582 -0
  250. package/src/tooltips/compute.ts +700 -0
  251. package/src/transforms/__tests__/aggregate.test.ts +159 -0
  252. package/src/transforms/__tests__/bin.test.ts +88 -0
  253. package/src/transforms/__tests__/calculate.test.ts +146 -0
  254. package/src/transforms/__tests__/conditional.test.ts +127 -0
  255. package/src/transforms/__tests__/filter-relative.test.ts +202 -0
  256. package/src/transforms/__tests__/filter.test.ts +59 -0
  257. package/src/transforms/__tests__/fold.test.ts +79 -0
  258. package/src/transforms/__tests__/index.test.ts +93 -0
  259. package/src/transforms/__tests__/predicates.test.ts +193 -0
  260. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  261. package/src/transforms/__tests__/window.test.ts +286 -0
  262. package/src/transforms/aggregate.ts +131 -0
  263. package/src/transforms/bin.ts +87 -0
  264. package/src/transforms/calculate.ts +60 -0
  265. package/src/transforms/conditional.ts +46 -0
  266. package/src/transforms/filter.ts +122 -0
  267. package/src/transforms/fold.ts +49 -0
  268. package/src/transforms/index.ts +60 -0
  269. package/src/transforms/predicates.ts +123 -0
  270. package/src/transforms/timeunit.ts +88 -0
  271. package/src/transforms/window.ts +185 -0
@@ -0,0 +1,44 @@
1
+ import type { NormalizedChrome } from '../compiler/types';
2
+
3
+ /** Convert NormalizedChrome back to a Chrome-compatible shape for computeChrome. */
4
+ export function chromeToInput(
5
+ chrome: NormalizedChrome,
6
+ ): import('@opendata-ai/openchart-core').Chrome {
7
+ return {
8
+ eyebrow: chrome.eyebrow,
9
+ title: chrome.title,
10
+ subtitle: chrome.subtitle,
11
+ source: chrome.source,
12
+ byline: chrome.byline,
13
+ footer: chrome.footer,
14
+ brand: chrome.brand,
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Scale padding based on the smaller container dimension.
20
+ * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
21
+ * Linear interpolation between 200-500px.
22
+ */
23
+ export function scalePadding(basePadding: number, width: number, height: number): number {
24
+ const minDim = Math.min(width, height);
25
+ if (minDim >= 500) return basePadding;
26
+ if (minDim <= 200) return Math.max(Math.round(basePadding * 0.5), 4);
27
+ const t = (minDim - 200) / 300;
28
+ return Math.max(Math.round(basePadding * (0.5 + t * 0.5)), 4);
29
+ }
30
+
31
+ /**
32
+ * Compute the bottom margin contribution from chrome.
33
+ * Padding always applies (gap between x-axis ticks and the chrome below).
34
+ * bottomHeight is additive on top.
35
+ */
36
+ export function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
37
+ return padding + bottomHeight + xAxisHeight;
38
+ }
39
+
40
+ /**
41
+ * Vertical breathing room added to the inline-tick label height so the
42
+ * topmost tick has clearance from chrome.
43
+ */
44
+ export const INLINE_TICK_OVERHANG_PAD = 6;
@@ -0,0 +1,318 @@
1
+ import { resolveTheme } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { compileChart } from '../../compile';
5
+ import { applyColorScaleRange } from '../../compile/color-scale-range';
6
+ import type { NormalizedChartSpec } from '../../compiler/types';
7
+ import { computeScales } from '../../layout/scales';
8
+ import { computeLegendContent, placeLegend } from '../compute';
9
+ import { sampleRampColors } from '../continuous';
10
+
11
+ const theme = resolveTheme({});
12
+ const strategy = {
13
+ axisLabelDensity: 'full' as const,
14
+ labelMode: 'all' as const,
15
+ legendPosition: 'top' as const,
16
+ legendMaxHeight: 1,
17
+ fontScale: 1,
18
+ };
19
+
20
+ const heatmapData = [
21
+ { month: 'Jan', value: 4 },
22
+ { month: 'Feb', value: 12 },
23
+ { month: 'Mar', value: 25 },
24
+ { month: 'Apr', value: 47 },
25
+ { month: 'May', value: 68 },
26
+ { month: 'Jun', value: 90 },
27
+ ];
28
+
29
+ function makeSpec(overrides: Partial<NormalizedChartSpec> = {}): NormalizedChartSpec {
30
+ return {
31
+ markType: 'bar',
32
+ markDef: { type: 'bar' },
33
+ data: heatmapData,
34
+ encoding: {
35
+ x: { field: 'month', type: 'nominal' },
36
+ y: { field: 'value', type: 'quantitative' },
37
+ color: { field: 'value', type: 'quantitative' },
38
+ },
39
+ chrome: {},
40
+ annotations: [],
41
+ responsive: true,
42
+ theme: {},
43
+ darkMode: 'off',
44
+ labels: { density: 'auto', format: '', prefix: '' },
45
+ hiddenSeries: [],
46
+ seriesStyles: {},
47
+ watermark: true,
48
+ display: 'full',
49
+ userExplicit: {
50
+ chrome: false,
51
+ legend: false,
52
+ endpointLabels: false,
53
+ xAxis: false,
54
+ yAxis: false,
55
+ labels: false,
56
+ animation: false,
57
+ watermark: false,
58
+ crosshair: false,
59
+ },
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ describe('continuous legend content', () => {
65
+ it('produces a gradient legend by default for a sequential color scale', () => {
66
+ const content = computeLegendContent(makeSpec(), strategy, theme, 600, 400);
67
+
68
+ expect(content.continuous).toBeDefined();
69
+ expect(content.continuous!.mode).toBe('gradient');
70
+ expect(content.entries).toHaveLength(0);
71
+ expect(content.position).toBe('top');
72
+ expect(content.height).toBeGreaterThan(0);
73
+ expect(content.legendWidth).toBe(content.continuous!.barWidth);
74
+
75
+ // Default ramp: theme's first sequential palette endpoints, exactly as
76
+ // applyColorScaleRange gives the marks.
77
+ const seqStops = Object.values(theme.colors.sequential)[0];
78
+ expect(content.continuous!.colorStops).toEqual([
79
+ { offset: 0, color: seqStops[0] },
80
+ { offset: 1, color: seqStops[seqStops.length - 1] },
81
+ ]);
82
+
83
+ // Sequential: min/max labels only, anchored at the bar ends.
84
+ const ticks = content.continuous!.ticks;
85
+ expect(ticks).toHaveLength(2);
86
+ expect(ticks[0]).toMatchObject({ value: 4, x: 0, anchor: 'start' });
87
+ expect(ticks[1]).toMatchObject({
88
+ value: 90,
89
+ x: content.continuous!.barWidth,
90
+ anchor: 'end',
91
+ });
92
+ });
93
+
94
+ it('uses tabular figures for value labels', () => {
95
+ const content = computeLegendContent(makeSpec(), strategy, theme, 600, 400);
96
+ expect(content.labelStyle.fontVariant).toBe('tabular-nums');
97
+ });
98
+
99
+ it('is removed by legend: { show: false }', () => {
100
+ const spec = makeSpec({ legend: { show: false } });
101
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
102
+ expect(content.continuous).toBeUndefined();
103
+ expect(content.entries).toHaveLength(0);
104
+ });
105
+
106
+ it('applies the channel format to labels', () => {
107
+ const spec = makeSpec({
108
+ encoding: {
109
+ x: { field: 'month', type: 'nominal' },
110
+ y: { field: 'value', type: 'quantitative' },
111
+ color: { field: 'value', type: 'quantitative', format: '.1f' },
112
+ },
113
+ });
114
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
115
+ expect(content.continuous!.ticks[0].label).toBe('4.0');
116
+ expect(content.continuous!.ticks[1].label).toBe('90.0');
117
+ });
118
+
119
+ it('adds a midpoint label at the center value for diverging ramps', () => {
120
+ const divergingStops = Object.values(theme.colors.diverging)[0];
121
+ const spec = makeSpec({
122
+ data: [
123
+ { month: 'Jan', value: -30 },
124
+ { month: 'Feb', value: 0 },
125
+ { month: 'Mar', value: 50 },
126
+ ],
127
+ encoding: {
128
+ x: { field: 'month', type: 'nominal' },
129
+ y: { field: 'value', type: 'quantitative' },
130
+ color: {
131
+ field: 'value',
132
+ type: 'quantitative',
133
+ scale: { range: divergingStops },
134
+ },
135
+ },
136
+ });
137
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
138
+
139
+ expect(content.continuous!.mode).toBe('gradient');
140
+ // All diverging stops interpolate piecewise.
141
+ expect(content.continuous!.colorStops).toHaveLength(divergingStops.length);
142
+ const ticks = content.continuous!.ticks;
143
+ expect(ticks).toHaveLength(3);
144
+ // Neutral at the scale's center value: (-30 + 50) / 2 = 10.
145
+ expect(ticks[1]).toMatchObject({
146
+ value: 10,
147
+ x: content.continuous!.barWidth / 2,
148
+ anchor: 'middle',
149
+ });
150
+ });
151
+
152
+ it('renders binned swatches with boundary labels for threshold scales', () => {
153
+ const breaks = [20, 40, 60, 80];
154
+ const spec = makeSpec({
155
+ encoding: {
156
+ x: { field: 'month', type: 'nominal' },
157
+ y: { field: 'value', type: 'quantitative' },
158
+ color: {
159
+ field: 'value',
160
+ type: 'quantitative',
161
+ scale: { type: 'threshold', domain: breaks },
162
+ },
163
+ },
164
+ });
165
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
166
+
167
+ expect(content.continuous!.mode).toBe('binned');
168
+ // 4 breaks -> 5 swatches with 4 boundary labels between them.
169
+ expect(content.continuous!.bins).toHaveLength(5);
170
+ expect(content.continuous!.ticks).toHaveLength(4);
171
+ const binWidth = content.continuous!.barWidth / 5;
172
+ content.continuous!.ticks.forEach((tick, i) => {
173
+ expect(tick.value).toBe(breaks[i]);
174
+ expect(tick.anchor).toBe('middle');
175
+ expect(tick.x).toBeCloseTo(binWidth * (i + 1), 6);
176
+ });
177
+ // Swatches are contiguous, left to right.
178
+ content.continuous!.bins.forEach((bin, i) => {
179
+ expect(bin.x).toBeCloseTo(binWidth * i, 6);
180
+ expect(bin.width).toBeCloseTo(binWidth, 6);
181
+ });
182
+ });
183
+
184
+ it('matches the mark color scale exactly (gradient endpoints)', () => {
185
+ const spec = makeSpec();
186
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
187
+ const scales = computeScales(spec, { x: 0, y: 0, width: 400, height: 300 }, spec.data);
188
+ applyColorScaleRange(scales, spec.encoding, theme);
189
+ const colorScale = scales.color!.scale as unknown as (v: number) => string;
190
+
191
+ // d3 linear color scales emit rgb(...) strings; normalize hex for comparison.
192
+ const hexToRgb = (hex: string) => {
193
+ const n = parseInt(hex.slice(1), 16);
194
+ return `rgb(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255})`;
195
+ };
196
+ const stops = content.continuous!.colorStops;
197
+ expect(hexToRgb(stops[0].color)).toBe(colorScale(4));
198
+ expect(hexToRgb(stops[stops.length - 1].color)).toBe(colorScale(90));
199
+ });
200
+
201
+ it('matches the mark color scale exactly (threshold class colors)', () => {
202
+ const spec = makeSpec({
203
+ encoding: {
204
+ x: { field: 'month', type: 'nominal' },
205
+ y: { field: 'value', type: 'quantitative' },
206
+ color: {
207
+ field: 'value',
208
+ type: 'quantitative',
209
+ scale: { type: 'threshold', domain: [20, 40, 60, 80] },
210
+ },
211
+ },
212
+ });
213
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
214
+ const scales = computeScales(spec, { x: 0, y: 0, width: 400, height: 300 }, spec.data);
215
+ applyColorScaleRange(scales, spec.encoding, theme);
216
+ const colorScale = scales.color!.scale as unknown as (v: number) => string;
217
+
218
+ // Sample one value per class and compare with the swatch colors.
219
+ const samples = [10, 30, 50, 70, 90];
220
+ content.continuous!.bins.forEach((bin, i) => {
221
+ expect(bin.color).toBe(colorScale(samples[i]));
222
+ });
223
+ });
224
+
225
+ it('supports legend position bottom; other positions resolve to top', () => {
226
+ const bottom = computeLegendContent(
227
+ makeSpec({ legend: { position: 'bottom' } }),
228
+ strategy,
229
+ theme,
230
+ 600,
231
+ 400,
232
+ );
233
+ expect(bottom.position).toBe('bottom');
234
+
235
+ const right = computeLegendContent(
236
+ makeSpec({ legend: { position: 'right' } }),
237
+ strategy,
238
+ theme,
239
+ 600,
240
+ 400,
241
+ );
242
+ expect(right.position).toBe('top');
243
+ });
244
+
245
+ it('clamps the bar width to the available width', () => {
246
+ const content = computeLegendContent(makeSpec(), strategy, theme, 120, 400);
247
+ expect(content.continuous!.barWidth).toBeLessThanOrEqual(120);
248
+ expect(content.legendWidth).toBeLessThanOrEqual(120);
249
+ });
250
+ });
251
+
252
+ describe('placeLegend (continuous)', () => {
253
+ const chartArea = { x: 50, y: 80, width: 500, height: 280 };
254
+
255
+ it('places a top continuous legend above the chart area with absolute geometry', () => {
256
+ const content = computeLegendContent(makeSpec(), strategy, theme, 600, 400);
257
+ const layout = placeLegend(content, chartArea, 600, theme, 0);
258
+
259
+ expect(layout.type).toBe('continuous');
260
+ if (layout.type !== 'continuous') return;
261
+ expect(layout.bounds.y + layout.bounds.height).toBeLessThanOrEqual(chartArea.y);
262
+ expect(layout.bar.x).toBe(layout.bounds.x);
263
+ expect(layout.bar.y).toBe(layout.bounds.y);
264
+ // Ticks offset into chart coordinates.
265
+ expect(layout.ticks[0].x).toBe(layout.bar.x);
266
+ expect(layout.ticks[layout.ticks.length - 1].x).toBe(layout.bar.x + layout.bar.width);
267
+ // Labels sit below the bar, inside the bounds.
268
+ expect(layout.labelY).toBeGreaterThan(layout.bar.y + layout.bar.height);
269
+ expect(layout.labelY).toBeLessThanOrEqual(layout.bounds.y + layout.bounds.height);
270
+ });
271
+ });
272
+
273
+ describe('compileChart integration', () => {
274
+ const heatmapChartSpec = {
275
+ mark: 'bar' as const,
276
+ data: heatmapData,
277
+ encoding: {
278
+ x: { field: 'month', type: 'nominal' as const },
279
+ y: { field: 'value', type: 'quantitative' as const },
280
+ color: { field: 'value', type: 'quantitative' as const },
281
+ },
282
+ };
283
+
284
+ it('reserves legend space so the bar never overlaps the chart area', () => {
285
+ const layout = compileChart(heatmapChartSpec, { width: 600, height: 400 });
286
+ expect(layout.legend.type).toBe('continuous');
287
+ expect(layout.legend.bounds.y + layout.legend.bounds.height).toBeLessThanOrEqual(layout.area.y);
288
+
289
+ const hidden = compileChart(
290
+ { ...heatmapChartSpec, legend: { show: false } },
291
+ { width: 600, height: 400 },
292
+ );
293
+ expect(hidden.legend.type).toBe('categorical');
294
+ expect(layout.area.y).toBeGreaterThan(hidden.area.y);
295
+ });
296
+
297
+ it('stays inside a 320px container', () => {
298
+ const layout = compileChart(heatmapChartSpec, { width: 320, height: 300 });
299
+ expect(layout.legend.type).toBe('continuous');
300
+ expect(layout.legend.bounds.x + layout.legend.bounds.width).toBeLessThanOrEqual(320);
301
+ expect(layout.legend.bounds.y + layout.legend.bounds.height).toBeLessThanOrEqual(layout.area.y);
302
+ });
303
+ });
304
+
305
+ describe('sampleRampColors', () => {
306
+ const ramp = ['#a', '#b', '#c', '#d', '#e', '#f'];
307
+
308
+ it('returns the ramp unchanged when lengths match', () => {
309
+ expect(sampleRampColors(ramp, 6)).toEqual(ramp);
310
+ });
311
+
312
+ it('keeps the endpoints when downsampling', () => {
313
+ const sampled = sampleRampColors(ramp, 3);
314
+ expect(sampled).toHaveLength(3);
315
+ expect(sampled[0]).toBe('#a');
316
+ expect(sampled[2]).toBe('#f');
317
+ });
318
+ });
@@ -0,0 +1,108 @@
1
+ import { resolveTheme } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import type { NormalizedChartSpec } from '../../compiler/types';
5
+ import { computeLegendContent } from '../compute';
6
+
7
+ const theme = resolveTheme({});
8
+ const strategy = {
9
+ axisLabelDensity: 'full' as const,
10
+ labelMode: 'all' as const,
11
+ legendPosition: 'top' as const,
12
+ legendMaxHeight: 1,
13
+ fontScale: 1,
14
+ };
15
+
16
+ function makeBarSpec(overrides: Partial<NormalizedChartSpec> = {}): NormalizedChartSpec {
17
+ return {
18
+ markType: 'bar',
19
+ markDef: { type: 'bar' },
20
+ data: [
21
+ { category: 'A', value: 10 },
22
+ { category: 'B', value: 20 },
23
+ { category: 'C', value: 30 },
24
+ ],
25
+ encoding: {
26
+ x: { field: 'category', type: 'nominal' },
27
+ y: { field: 'value', type: 'quantitative' },
28
+ color: { field: 'category', type: 'nominal' },
29
+ },
30
+ chrome: {},
31
+ annotations: [],
32
+ responsive: true,
33
+ theme: {},
34
+ darkMode: 'off',
35
+ labels: { density: 'auto', format: '', prefix: '' },
36
+ hiddenSeries: [],
37
+ seriesStyles: {},
38
+ watermark: true,
39
+ display: 'full',
40
+ userExplicit: {
41
+ chrome: false,
42
+ legend: false,
43
+ endpointLabels: false,
44
+ xAxis: false,
45
+ yAxis: false,
46
+ labels: false,
47
+ animation: false,
48
+ watermark: false,
49
+ crosshair: false,
50
+ },
51
+ ...overrides,
52
+ };
53
+ }
54
+
55
+ describe('bar/column legend redundancy rule', () => {
56
+ it('hides legend when color.field matches the category axis field', () => {
57
+ const spec = makeBarSpec();
58
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
59
+ expect(content.entries).toHaveLength(0);
60
+ });
61
+
62
+ it('shows legend when user explicitly sets legend config', () => {
63
+ const spec = makeBarSpec({
64
+ legend: { show: true },
65
+ userExplicit: {
66
+ chrome: false,
67
+ legend: true,
68
+ endpointLabels: false,
69
+ xAxis: false,
70
+ yAxis: false,
71
+ labels: false,
72
+ animation: false,
73
+ watermark: false,
74
+ crosshair: false,
75
+ },
76
+ });
77
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
78
+ expect(content.entries.length).toBeGreaterThan(0);
79
+ });
80
+
81
+ it('shows legend when color.field differs from category axis field', () => {
82
+ const spec = makeBarSpec({
83
+ data: [
84
+ { category: 'A', value: 10, series: 'X' },
85
+ { category: 'B', value: 20, series: 'Y' },
86
+ ],
87
+ encoding: {
88
+ x: { field: 'category', type: 'nominal' },
89
+ y: { field: 'value', type: 'quantitative' },
90
+ color: { field: 'series', type: 'nominal' },
91
+ },
92
+ });
93
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
94
+ expect(content.entries.length).toBeGreaterThan(0);
95
+ });
96
+
97
+ it('works for horizontal bars (y is category, x is quantitative)', () => {
98
+ const spec = makeBarSpec({
99
+ encoding: {
100
+ x: { field: 'value', type: 'quantitative' },
101
+ y: { field: 'category', type: 'nominal' },
102
+ color: { field: 'category', type: 'nominal' },
103
+ },
104
+ });
105
+ const content = computeLegendContent(spec, strategy, theme, 600, 400);
106
+ expect(content.entries).toHaveLength(0);
107
+ });
108
+ });