@opendata-ai/openchart-engine 8.0.0-rc.2 → 8.0.0-rc.20

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 +140 -2
  2. package/dist/index.js +1403 -210
  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 -2339
  7. package/src/__tests__/annotation-behavior.test.ts +0 -721
  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 -370
  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 -539
  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 -626
  133. package/src/compile/size-scale.ts +0 -139
  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 -1450
  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 -86
  165. package/src/compiler/__tests__/validate-highlight.test.ts +0 -112
  166. package/src/compiler/__tests__/validate-near-miss.test.ts +0 -89
  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 -733
  174. package/src/compiler/sparkline-defaults.ts +0 -138
  175. package/src/compiler/types.ts +0 -241
  176. package/src/compiler/validate.ts +0 -1810
  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 -621
  185. package/src/geo/compile-map.ts +0 -856
  186. package/src/geo/join.ts +0 -115
  187. package/src/geo/projections.ts +0 -45
  188. package/src/geo/types.ts +0 -24
  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 -747
  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,1579 +0,0 @@
1
- import type { AxisTick, Encoding, LayoutStrategy } from '@opendata-ai/openchart-core';
2
- import { maxRotatedLabelWidth, resolveTheme } from '@opendata-ai/openchart-core';
3
- import { scaleLinear, scaleLog } from 'd3-scale';
4
- import { describe, expect, it } from 'vitest';
5
- import type { NormalizedChartSpec } from '../compiler/types';
6
- import { computeAxes, effectiveDensity, thinTicksUntilFit, ticksOverlap } from '../layout/axes';
7
- import { buildContinuousTicks } from '../layout/axes/ticks';
8
- import type { ResolvedScale } from '../layout/scales';
9
- import { computeScales } from '../layout/scales';
10
-
11
- const lineSpec: NormalizedChartSpec = {
12
- markType: 'line',
13
- markDef: { type: 'line' },
14
- data: [
15
- { date: '2020-01-01', value: 100 },
16
- { date: '2021-01-01', value: 500 },
17
- { date: '2022-01-01', value: 300 },
18
- ],
19
- encoding: {
20
- x: { field: 'date', type: 'temporal' },
21
- y: { field: 'value', type: 'quantitative' },
22
- },
23
- chrome: {},
24
- annotations: [],
25
- responsive: true,
26
- theme: {},
27
- darkMode: 'off',
28
- labels: { density: 'auto', format: '' },
29
- };
30
-
31
- const chartArea = { x: 50, y: 50, width: 500, height: 300 };
32
- const theme = resolveTheme();
33
-
34
- const fullStrategy: LayoutStrategy = {
35
- labelMode: 'all',
36
- legendPosition: 'right',
37
- annotationPosition: 'inline',
38
- axisLabelDensity: 'full',
39
- chromeMode: 'full',
40
- legendMaxHeight: -1,
41
- };
42
-
43
- const minimalStrategy: LayoutStrategy = {
44
- labelMode: 'none',
45
- legendPosition: 'top',
46
- annotationPosition: 'tooltip-only',
47
- axisLabelDensity: 'minimal',
48
- chromeMode: 'full',
49
- legendMaxHeight: -1,
50
- };
51
-
52
- describe('computeAxes', () => {
53
- it('produces x and y axes for a line chart', () => {
54
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
55
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
56
-
57
- expect(axes.x).toBeDefined();
58
- expect(axes.y).toBeDefined();
59
- });
60
-
61
- it('generates ticks for both axes', () => {
62
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
63
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
64
-
65
- expect(axes.x!.ticks.length).toBeGreaterThan(0);
66
- expect(axes.y!.ticks.length).toBeGreaterThan(0);
67
- });
68
-
69
- it('tick positions are within chart area', () => {
70
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
71
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
72
-
73
- for (const tick of axes.x!.ticks) {
74
- expect(tick.position).toBeGreaterThanOrEqual(chartArea.x - 1);
75
- expect(tick.position).toBeLessThanOrEqual(chartArea.x + chartArea.width + 1);
76
- }
77
-
78
- for (const tick of axes.y!.ticks) {
79
- expect(tick.position).toBeGreaterThanOrEqual(chartArea.y - 1);
80
- expect(tick.position).toBeLessThanOrEqual(chartArea.y + chartArea.height + 1);
81
- }
82
- });
83
-
84
- it('tick labels are formatted strings', () => {
85
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
86
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
87
-
88
- for (const tick of axes.y!.ticks) {
89
- expect(typeof tick.label).toBe('string');
90
- expect(tick.label.length).toBeGreaterThan(0);
91
- }
92
- });
93
-
94
- it('produces fewer ticks with minimal density', () => {
95
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
96
- const axesFull = computeAxes(scales, chartArea, fullStrategy, theme);
97
- const axesMinimal = computeAxes(scales, chartArea, minimalStrategy, theme);
98
-
99
- // Minimal should have fewer or equal ticks
100
- expect(axesMinimal.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
101
- });
102
-
103
- it('y-axis has gridlines by default', () => {
104
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
105
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
106
-
107
- expect(axes.y!.gridlines.length).toBeGreaterThan(0);
108
- });
109
-
110
- it('axes have correct start/end positions', () => {
111
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
112
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
113
-
114
- // X axis sits at the bottom of the chart area
115
- expect(axes.x!.start.y).toBe(chartArea.y + chartArea.height);
116
- expect(axes.x!.end.y).toBe(chartArea.y + chartArea.height);
117
-
118
- // Y axis sits at the left of the chart area
119
- expect(axes.y!.start.x).toBe(chartArea.x);
120
- expect(axes.y!.end.x).toBe(chartArea.x);
121
- });
122
-
123
- // -------------------------------------------------------------------------
124
- // Height-aware y-axis tick reduction
125
- // -------------------------------------------------------------------------
126
-
127
- it('reduces y-axis ticks for very short chart areas (< 80px)', () => {
128
- const shortArea = { x: 50, y: 50, width: 500, height: 80 };
129
- const scales = computeScales(lineSpec, shortArea, lineSpec.data);
130
- const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
131
-
132
- // Very short chart area -- tick count clamped to at most 4
133
- expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(4);
134
- });
135
-
136
- it('reduces y-axis ticks for medium-short chart areas (80-100px)', () => {
137
- const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
138
- const tallArea = { x: 50, y: 50, width: 500, height: 400 };
139
-
140
- const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
141
- const scalesTall = computeScales(lineSpec, tallArea, lineSpec.data);
142
-
143
- const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
144
- const axesTall = computeAxes(scalesTall, tallArea, fullStrategy, theme);
145
-
146
- // Medium height should have fewer ticks than a tall chart with same 'full' density
147
- expect(axesMedium.y!.ticks.length).toBeLessThanOrEqual(axesTall.y!.ticks.length);
148
- });
149
-
150
- it('does not increase y-axis ticks beyond base density for short charts', () => {
151
- const shortArea = { x: 50, y: 50, width: 500, height: 80 };
152
- const scales = computeScales(lineSpec, shortArea, lineSpec.data);
153
-
154
- // Strategy already says minimal - short height shouldn't change anything
155
- const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
156
- expect(axes.y!.ticks.length).toBeLessThanOrEqual(4);
157
- });
158
-
159
- // -------------------------------------------------------------------------
160
- // Width-aware x-axis tick reduction
161
- // -------------------------------------------------------------------------
162
-
163
- it('reduces x-axis ticks for very narrow chart areas (< 150px)', () => {
164
- const narrowArea = { x: 50, y: 50, width: 100, height: 300 };
165
- const scales = computeScales(lineSpec, narrowArea, lineSpec.data);
166
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
167
-
168
- // Width < 150 forces minimal density for x-axis
169
- expect(axes.x!.ticks.length).toBeLessThanOrEqual(4);
170
- });
171
-
172
- it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
173
- const mediumArea = { x: 50, y: 50, width: 250, height: 300 };
174
- const wideArea = { x: 50, y: 50, width: 600, height: 300 };
175
-
176
- const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
177
- const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
178
-
179
- const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
180
- const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
181
-
182
- expect(axesMedium.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
183
- });
184
-
185
- // -------------------------------------------------------------------------
186
- // Both axes constrained simultaneously (thumbnail scenario)
187
- // -------------------------------------------------------------------------
188
-
189
- it('reduces ticks on both axes for thumbnail-sized charts', () => {
190
- const thumbnailArea = { x: 10, y: 10, width: 120, height: 80 };
191
- const fullArea = { x: 50, y: 50, width: 600, height: 400 };
192
-
193
- const scalesThumb = computeScales(lineSpec, thumbnailArea, lineSpec.data);
194
- const scalesFull = computeScales(lineSpec, fullArea, lineSpec.data);
195
-
196
- const axesThumb = computeAxes(scalesThumb, thumbnailArea, fullStrategy, theme);
197
- const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
198
-
199
- // Both axes should have minimal ticks in a thumbnail
200
- expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(4);
201
- expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(4);
202
-
203
- // And fewer than full-size
204
- expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
205
- expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
206
- });
207
-
208
- // -------------------------------------------------------------------------
209
- // labelAngle propagation
210
- // -------------------------------------------------------------------------
211
-
212
- it('propagates labelAngle from encoding to x-axis layout', () => {
213
- const specWithAngle: NormalizedChartSpec = {
214
- ...lineSpec,
215
- markType: 'bar',
216
- markDef: { type: 'bar', orient: 'vertical' },
217
- data: [
218
- { cat: 'California', val: 10 },
219
- { cat: 'New York', val: 20 },
220
- ],
221
- encoding: {
222
- x: { field: 'cat', type: 'nominal', axis: { labelAngle: -90 } },
223
- y: { field: 'val', type: 'quantitative' },
224
- },
225
- };
226
- const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
227
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
228
-
229
- expect(axes.x!.tickAngle).toBe(-90);
230
- });
231
-
232
- it('leaves tickAngle undefined when not specified', () => {
233
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
234
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
235
-
236
- expect(axes.x!.tickAngle).toBeUndefined();
237
- expect(axes.y!.tickAngle).toBeUndefined();
238
- });
239
-
240
- it('propagates labelAngle to y-axis layout', () => {
241
- const specWithAngle: NormalizedChartSpec = {
242
- ...lineSpec,
243
- encoding: {
244
- x: { field: 'date', type: 'temporal' },
245
- y: { field: 'value', type: 'quantitative', axis: { labelAngle: -45 } },
246
- },
247
- };
248
- const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
249
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
250
-
251
- expect(axes.y!.tickAngle).toBe(-45);
252
- });
253
- });
254
-
255
- // ---------------------------------------------------------------------------
256
- // effectiveDensity unit tests
257
- // ---------------------------------------------------------------------------
258
-
259
- describe('effectiveDensity', () => {
260
- const MINIMAL_THRESHOLD = 120;
261
- const REDUCED_THRESHOLD = 200;
262
-
263
- it('returns base density when axis length exceeds all thresholds', () => {
264
- expect(effectiveDensity('full', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
265
- expect(effectiveDensity('reduced', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
266
- expect(effectiveDensity('minimal', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
267
- });
268
-
269
- it('forces minimal density below the minimal threshold', () => {
270
- expect(effectiveDensity('full', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
271
- expect(effectiveDensity('reduced', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
272
- expect(effectiveDensity('minimal', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
273
- });
274
-
275
- it('caps at reduced density between thresholds', () => {
276
- // 'full' base should step down to 'reduced'
277
- expect(effectiveDensity('full', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
278
- });
279
-
280
- it('does not increase density beyond base when between thresholds', () => {
281
- // 'minimal' base should stay 'minimal' even between thresholds
282
- expect(effectiveDensity('minimal', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
283
- // 'reduced' base should stay 'reduced'
284
- expect(effectiveDensity('reduced', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
285
- });
286
-
287
- it('handles exact threshold boundaries', () => {
288
- // At exactly the minimal threshold, we're NOT below it
289
- expect(effectiveDensity('full', 120, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
290
- // At exactly the reduced threshold, we're NOT below it
291
- expect(effectiveDensity('full', 200, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
292
- });
293
-
294
- it('handles zero and negative lengths', () => {
295
- expect(effectiveDensity('full', 0, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
296
- expect(effectiveDensity('full', -10, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
297
- });
298
-
299
- it('works with custom thresholds (x-axis uses different values)', () => {
300
- const X_MINIMAL = 150;
301
- const X_REDUCED = 300;
302
-
303
- expect(effectiveDensity('full', 100, X_MINIMAL, X_REDUCED)).toBe('minimal');
304
- expect(effectiveDensity('full', 200, X_MINIMAL, X_REDUCED)).toBe('reduced');
305
- expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
306
- });
307
- });
308
-
309
- // ---------------------------------------------------------------------------
310
- // ticksOverlap unit tests
311
- // ---------------------------------------------------------------------------
312
-
313
- describe('ticksOverlap', () => {
314
- const fontSize = 12;
315
- const fontWeight = 400;
316
-
317
- it('returns false for empty or single tick', () => {
318
- expect(ticksOverlap([], fontSize, fontWeight)).toBe(false);
319
- expect(ticksOverlap([{ value: 0, position: 100, label: 'A' }], fontSize, fontWeight)).toBe(
320
- false,
321
- );
322
- });
323
-
324
- it('returns false when ticks are well-spaced', () => {
325
- const ticks: AxisTick[] = [
326
- { value: 0, position: 0, label: 'A' },
327
- { value: 1, position: 200, label: 'B' },
328
- { value: 2, position: 400, label: 'C' },
329
- ];
330
- expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(false);
331
- });
332
-
333
- it('returns true when ticks are too close together', () => {
334
- const ticks: AxisTick[] = [
335
- { value: 0, position: 0, label: 'January 2025' },
336
- { value: 1, position: 30, label: 'February 2025' },
337
- { value: 2, position: 60, label: 'March 2025' },
338
- ];
339
- expect(ticksOverlap(ticks, fontSize, fontWeight)).toBe(true);
340
- });
341
-
342
- it('uses measureText when provided', () => {
343
- const ticks: AxisTick[] = [
344
- { value: 0, position: 0, label: 'A' },
345
- { value: 1, position: 100, label: 'B' },
346
- ];
347
-
348
- // With a measureText that reports very wide labels, they should overlap
349
- const wideMeasure = () => ({ width: 200, height: 12 });
350
- expect(ticksOverlap(ticks, fontSize, fontWeight, wideMeasure)).toBe(true);
351
-
352
- // With a measureText that reports very narrow labels, they should not overlap
353
- const narrowMeasure = () => ({ width: 1, height: 12 });
354
- expect(ticksOverlap(ticks, fontSize, fontWeight, narrowMeasure)).toBe(false);
355
- });
356
- });
357
-
358
- // ---------------------------------------------------------------------------
359
- // thinTicksUntilFit unit tests
360
- // ---------------------------------------------------------------------------
361
-
362
- describe('thinTicksUntilFit', () => {
363
- const fontSize = 12;
364
- const fontWeight = 400;
365
-
366
- it('returns original array when no overlap', () => {
367
- const ticks: AxisTick[] = [
368
- { value: 0, position: 0, label: 'A' },
369
- { value: 1, position: 200, label: 'B' },
370
- { value: 2, position: 400, label: 'C' },
371
- ];
372
- const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
373
- expect(result).toBe(ticks); // Same reference, not a copy
374
- });
375
-
376
- it('thins overlapping ticks while keeping first and last', () => {
377
- // Ticks at 10px intervals with long labels that will overlap
378
- const ticks: AxisTick[] = Array.from({ length: 8 }, (_, i) => ({
379
- value: i,
380
- position: i * 10,
381
- label: 'Long Label Text',
382
- }));
383
-
384
- const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
385
-
386
- // Should have fewer ticks than the original
387
- expect(result.length).toBeLessThan(ticks.length);
388
- // Should always keep first and last
389
- expect(result[0]).toBe(ticks[0]);
390
- expect(result[result.length - 1]).toBe(ticks[ticks.length - 1]);
391
- // Should have at least MIN_TICK_COUNT (2)
392
- expect(result.length).toBeGreaterThanOrEqual(2);
393
- });
394
-
395
- it('returns at least 2 ticks even when labels are very wide', () => {
396
- const ticks: AxisTick[] = [
397
- { value: 0, position: 0, label: 'Very Long Label That Is Wide' },
398
- { value: 1, position: 5, label: 'Another Very Long Label' },
399
- { value: 2, position: 10, label: 'Yet Another Long Label Here' },
400
- ];
401
- const result = thinTicksUntilFit(ticks, fontSize, fontWeight);
402
- expect(result.length).toBeGreaterThanOrEqual(2);
403
- });
404
- });
405
-
406
- // ---------------------------------------------------------------------------
407
- // Text-aware tick density integration tests
408
- // ---------------------------------------------------------------------------
409
-
410
- describe('text-aware tick density', () => {
411
- it('produces fewer x-axis ticks in narrow charts', () => {
412
- const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
413
- const wideArea = { x: 50, y: 50, width: 800, height: 300 };
414
-
415
- const scalesNarrow = computeScales(lineSpec, narrowArea, lineSpec.data);
416
- const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
417
-
418
- const axesNarrow = computeAxes(scalesNarrow, narrowArea, fullStrategy, theme);
419
- const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
420
-
421
- expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
422
- });
423
-
424
- it('still thins x-axis ticks when tickCount is set but D3 overshoots', () => {
425
- const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
426
- const specWithTickCount: NormalizedChartSpec = {
427
- ...lineSpec,
428
- encoding: {
429
- x: { field: 'date', type: 'temporal', axis: { tickCount: 8 } },
430
- y: { field: 'value', type: 'quantitative' },
431
- },
432
- };
433
-
434
- const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
435
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
436
-
437
- // tickCount is advisory for D3 - if it overshoots, thinning still applies
438
- // to prevent overlap. The result should still have ticks, just not more
439
- // than the narrow area can display without overlap.
440
- expect(axes.x!.ticks.length).toBeGreaterThan(0);
441
- });
442
-
443
- it('does not thin x-axis ticks when explicit values are set', () => {
444
- const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
445
- const specWithValues: NormalizedChartSpec = {
446
- ...lineSpec,
447
- encoding: {
448
- x: {
449
- field: 'date',
450
- type: 'temporal',
451
- axis: { values: ['2020-01-01', '2021-01-01', '2022-01-01'] },
452
- },
453
- y: { field: 'value', type: 'quantitative' },
454
- },
455
- };
456
-
457
- const scales = computeScales(specWithValues, narrowArea, specWithValues.data);
458
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
459
-
460
- // Explicit values should be preserved exactly as specified
461
- expect(axes.x!.ticks.length).toBe(3);
462
- });
463
-
464
- it('band scale shows all categories regardless of width', () => {
465
- const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
466
- const barSpec: NormalizedChartSpec = {
467
- ...lineSpec,
468
- markType: 'bar',
469
- markDef: { type: 'bar', orient: 'vertical' },
470
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
471
- encoding: {
472
- x: { field: 'cat', type: 'nominal' },
473
- y: { field: 'val', type: 'quantitative' },
474
- },
475
- };
476
-
477
- const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
478
- const scales = computeScales(barSpec, narrowArea, barSpec.data);
479
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
480
-
481
- // Band scales show all categories (auto-rotation handles overlap instead)
482
- expect(axes.x!.ticks.length).toBe(categories.length);
483
- });
484
-
485
- it('keeps all band labels when one label is much wider (no decimation)', () => {
486
- // Regression: 5 rotated year labels where "2026 (to wk 17)" is much wider
487
- // than the others. Label width cannot cause rotated-label collisions
488
- // (parallel ribbons — see ./layout/axes/rotation), so every label is kept.
489
- // The old span-based thinning dropped "2023" and "2025" here.
490
- const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
491
- const barSpec: NormalizedChartSpec = {
492
- ...lineSpec,
493
- markType: 'bar',
494
- markDef: { type: 'bar', orient: 'vertical' },
495
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
496
- encoding: {
497
- x: { field: 'cat', type: 'nominal' },
498
- y: { field: 'val', type: 'quantitative' },
499
- },
500
- };
501
-
502
- // measureText reports the wide label as genuinely wide and the year labels
503
- // as narrow, matching real glyph metrics.
504
- const measure = (text: string) => ({
505
- width: text.length * 8,
506
- height: 12,
507
- });
508
-
509
- // Narrow area (280px) forces -45° rotation. Every label survives — the
510
- // ~47px step separates the diagonal ribbons by far more than a line height.
511
- const narrowArea = { x: 50, y: 50, width: 280, height: 300 };
512
- const narrowScales = computeScales(barSpec, narrowArea, barSpec.data);
513
- const narrowAxes = computeAxes(narrowScales, narrowArea, fullStrategy, theme, measure);
514
- expect(narrowAxes.x!.tickAngle).toBe(-45);
515
- expect(narrowAxes.x!.ticks.map((t) => t.label)).toEqual(categories);
516
-
517
- // Wider area (500px) has room for every label — none are thinned.
518
- const wideArea = { x: 50, y: 50, width: 500, height: 300 };
519
- const wideScales = computeScales(barSpec, wideArea, barSpec.data);
520
- const wideAxes = computeAxes(wideScales, wideArea, fullStrategy, theme, measure);
521
- expect(wideAxes.x!.ticks.length).toBe(categories.length);
522
- });
523
-
524
- it('keeps a short neighbor beside a wide final label at phone width', () => {
525
- // Regression (mobile): "2025" beside "2026 (to wk 17)" at ~300px. Two
526
- // earlier span-model revisions dropped "2025" here (once via minGap
527
- // comfort padding, once via a genuine 1-D span overlap at larger fonts)
528
- // even though the rendered -45° diagonals never touch.
529
- const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
530
- const barSpec: NormalizedChartSpec = {
531
- ...lineSpec,
532
- markType: 'bar',
533
- markDef: { type: 'bar', orient: 'vertical' },
534
- data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2000, 1600][i] })),
535
- encoding: {
536
- x: { field: 'cat', type: 'nominal' },
537
- y: { field: 'val', type: 'quantitative' },
538
- },
539
- };
540
-
541
- // Uses default (real) text measurement. At 300px the band centers sit ~56px
542
- // apart; the year labels project to ~17px at -45° and the wide label to
543
- // ~54px, leaving "2025" and "2026 (to wk 17)" ~2px apart — a near miss.
544
- const area = { x: 60, y: 50, width: 300, height: 300 };
545
- const scales = computeScales(barSpec, area, barSpec.data);
546
- const axes = computeAxes(scales, area, fullStrategy, theme);
547
- expect(axes.x!.tickAngle).toBe(-45);
548
- const kept = axes.x!.ticks.map((t) => t.label);
549
- expect(kept).toContain('2025');
550
- expect(kept).toContain('2026 (to wk 17)');
551
- expect(kept.length).toBe(categories.length);
552
- });
553
-
554
- it('keeps all labels with a larger themed tick font at phone width (deployed blog regression)', () => {
555
- // Regression: the deployed blog theme sets axisTick: 14 (default 11). At
556
- // ~61px steps the larger font made the 1-D span overlap genuine, so 7.9.2
557
- // still dropped "2025" in production while the default theme passed every
558
- // test. Ribbon separation at -45° is 61·sin45 ≈ 43px vs the ~21px one line
559
- // at 14px needs — the labels never touch, so all five must survive.
560
- const categories = ['2022', '2023', '2024', '2025', '2026 (to wk 17)'];
561
- const barSpec: NormalizedChartSpec = {
562
- ...lineSpec,
563
- markType: 'bar',
564
- markDef: { type: 'bar', orient: 'vertical' },
565
- data: categories.map((cat, i) => ({ cat, val: [122, 47, 266, 2026, 1602][i] })),
566
- encoding: {
567
- x: { field: 'cat', type: 'nominal' },
568
- y: { field: 'val', type: 'quantitative' },
569
- },
570
- };
571
- const blogTheme = resolveTheme({ fonts: { sizes: { axisTick: 14 } } });
572
-
573
- const area = { x: 60, y: 50, width: 330, height: 300 };
574
- const scales = computeScales(barSpec, area, barSpec.data);
575
- const axes = computeAxes(scales, area, fullStrategy, blogTheme);
576
- expect(axes.x!.tickAngle).toBe(-45);
577
- expect(axes.x!.ticks.map((t) => t.label)).toEqual(categories);
578
- });
579
-
580
- it('keeps every label when one is very long — rotated labels are parallel ribbons', () => {
581
- // A very long final category does NOT force neighbors to drop. Rotated
582
- // labels all lean at the same angle from their own anchors, so adjacent
583
- // labels are parallel ribbons separated by step·sin(angle) — label LENGTH
584
- // is irrelevant to collision. The old 1-D horizontal-span model reported
585
- // phantom overlaps here and dropped "B", "C", and "D".
586
- const categories = ['A', 'B', 'C', 'D', 'This Is A Very Long Final Category Name'];
587
- const barSpec: NormalizedChartSpec = {
588
- ...lineSpec,
589
- markType: 'bar',
590
- markDef: { type: 'bar', orient: 'vertical' },
591
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
592
- encoding: {
593
- x: { field: 'cat', type: 'nominal' },
594
- y: { field: 'val', type: 'quantitative' },
595
- },
596
- };
597
- const measure = (text: string) => ({ width: text.length * 8, height: 12 });
598
- const narrowArea = { x: 50, y: 50, width: 260, height: 300 };
599
- const scales = computeScales(barSpec, narrowArea, barSpec.data);
600
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme, measure);
601
- expect(axes.x!.tickAngle).toBe(-45);
602
-
603
- // Every category keeps a tick — the diagonals never touch, so no label is
604
- // DROPPED. (The long one is ellipsized to fit the capped rotated-label
605
- // band, which is a separate concern from decimation; its full text is
606
- // preserved on `fullLabel`.)
607
- expect(axes.x!.ticks).toHaveLength(categories.length);
608
- const kept = axes.x!.ticks.map((t) => t.fullLabel ?? t.label);
609
- expect(kept).toEqual(categories);
610
- });
611
-
612
- it('truncates rotated labels that would overflow the capped reservation', () => {
613
- // Regression: the layout clamps the reserved rotated-label band to
614
- // X_AXIS_ROTATED_EXTENT_CAP (120px) but nothing shortened the drawn string,
615
- // so a long label rendered straight past the axis and through the source
616
- // line. Long labels must now be ellipsized to fit that band, with the full
617
- // text retained on `fullLabel` for tooltips and the a11y table.
618
- const categories = ['Short', 'An Extremely Long Category Label That Cannot Possibly Fit'];
619
- const barSpec: NormalizedChartSpec = {
620
- ...lineSpec,
621
- markType: 'bar',
622
- markDef: { type: 'bar', orient: 'vertical' },
623
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
624
- encoding: {
625
- x: { field: 'cat', type: 'nominal' },
626
- y: { field: 'val', type: 'quantitative' },
627
- },
628
- };
629
- const measure = (text: string) => ({ width: text.length * 8, height: 12 });
630
- const area = { x: 50, y: 50, width: 260, height: 300 };
631
- const scales = computeScales(barSpec, area, barSpec.data);
632
- const axes = computeAxes(scales, area, fullStrategy, theme, measure);
633
-
634
- const ticks = axes.x!.ticks;
635
- const angle = axes.x!.tickAngle!;
636
- expect(Math.abs(angle)).toBeGreaterThan(10);
637
-
638
- // The short label is untouched and carries no fullLabel.
639
- const short = ticks.find((t) => t.value === 'Short')!;
640
- expect(short.label).toBe('Short');
641
- expect(short.fullLabel).toBeUndefined();
642
-
643
- // The long one is ellipsized, but its full text survives on fullLabel.
644
- const long = ticks.find((t) => t.value === categories[1])!;
645
- expect(long.label).not.toBe(categories[1]);
646
- expect(long.label.endsWith('…')).toBe(true);
647
- expect(long.fullLabel).toBe(categories[1]);
648
-
649
- // And the drawn label now fits the budget the reservation is computed from,
650
- // so it cannot overflow the band.
651
- const budget = maxRotatedLabelWidth(angle, theme.fonts.sizes.axisTick);
652
- expect(measure(long.label).width).toBeLessThanOrEqual(budget);
653
- });
654
-
655
- it('measures collision at the drawn tick spacing when an explicit tickCount thins upstream', () => {
656
- // 40 categories with tickCount: 8 — categoricalTicks retains every 5th
657
- // category, so the drawn ticks sit 5 band steps apart. The angle and
658
- // stride must use that real spacing: at ~45px apart the 8 labels fit at
659
- // -45° with no thinning. Keying on the raw ~9px band step would wrongly
660
- // escalate to -90° and stride away half the requested labels.
661
- const categories = Array.from({ length: 40 }, (_, i) => `Cat ${i + 1}`);
662
- const barSpec: NormalizedChartSpec = {
663
- ...lineSpec,
664
- markType: 'bar',
665
- markDef: { type: 'bar', orient: 'vertical' },
666
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
667
- encoding: {
668
- x: { field: 'cat', type: 'nominal', axis: { tickCount: 8 } },
669
- y: { field: 'val', type: 'quantitative' },
670
- },
671
- };
672
- const measure = (text: string) => ({ width: text.length * 6, height: 12 });
673
- const area = { x: 50, y: 50, width: 360, height: 300 };
674
- const scales = computeScales(barSpec, area, barSpec.data);
675
- const axes = computeAxes(scales, area, fullStrategy, theme, measure);
676
-
677
- expect(axes.x!.tickAngle).toBe(-45);
678
- expect(axes.x!.ticks.length).toBe(8);
679
- });
680
-
681
- it('applies the last-anchored stride when even vertical labels cannot fit', () => {
682
- // 40 short categories on a 360px chart: ~9px steps fit nothing at any
683
- // angle (a vertical label ribbon needs ~17px), so the safety net keeps
684
- // every 2nd label counting back from the LAST category.
685
- const categories = Array.from({ length: 40 }, (_, i) => `C${i + 1}`);
686
- const barSpec: NormalizedChartSpec = {
687
- ...lineSpec,
688
- markType: 'bar',
689
- markDef: { type: 'bar', orient: 'vertical' },
690
- data: categories.map((cat, i) => ({ cat, val: (i + 1) * 10 })),
691
- encoding: {
692
- x: { field: 'cat', type: 'nominal' },
693
- y: { field: 'val', type: 'quantitative' },
694
- },
695
- };
696
- const measure = (text: string) => ({ width: text.length * 6, height: 12 });
697
- const area = { x: 50, y: 50, width: 360, height: 300 };
698
- const scales = computeScales(barSpec, area, barSpec.data);
699
- const axes = computeAxes(scales, area, fullStrategy, theme, measure);
700
-
701
- expect(axes.x!.tickAngle).toBe(-90);
702
- const kept = axes.x!.ticks.map((t) => t.label);
703
- expect(kept.length).toBe(20);
704
- // The stride anchors at the final category — the most recent one is
705
- // always labeled.
706
- expect(kept[kept.length - 1]).toBe('C40');
707
- });
708
-
709
- it('y-axis gridlines match ticks so every gridline has a label', () => {
710
- // Force thinning by using a measureText that reports wide labels
711
- const wideMeasure = () => ({ width: 200, height: 12 });
712
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
713
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
714
-
715
- // Y-axis gridlines should always match ticks 1:1 (every gridline gets a label)
716
- expect(axes.y!.gridlines.length).toBe(axes.y!.ticks.length);
717
- for (let i = 0; i < axes.y!.ticks.length; i++) {
718
- expect(axes.y!.gridlines[i].position).toBe(axes.y!.ticks[i].position);
719
- }
720
- });
721
-
722
- it('x-axis gridlines survive tick thinning when grid is enabled', () => {
723
- const specWithGrid: NormalizedChartSpec = {
724
- ...lineSpec,
725
- encoding: {
726
- x: { field: 'date', type: 'temporal', axis: { grid: true } },
727
- y: { field: 'value', type: 'quantitative' },
728
- },
729
- };
730
-
731
- const wideMeasure = () => ({ width: 200, height: 12 });
732
- const scales = computeScales(specWithGrid, chartArea, specWithGrid.data);
733
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
734
-
735
- expect(axes.x!.gridlines.length).toBeGreaterThanOrEqual(axes.x!.ticks.length);
736
- });
737
-
738
- it('passes measureText to auto-rotation detection', () => {
739
- const barSpec: NormalizedChartSpec = {
740
- ...lineSpec,
741
- markType: 'bar',
742
- markDef: { type: 'bar', orient: 'vertical' },
743
- data: [
744
- { cat: 'A', val: 10 },
745
- { cat: 'B', val: 20 },
746
- ],
747
- encoding: {
748
- x: { field: 'cat', type: 'nominal' },
749
- y: { field: 'val', type: 'quantitative' },
750
- },
751
- };
752
-
753
- // measureText that reports very wide labels should trigger rotation
754
- const wideMeasure = () => ({ width: 1000, height: 12 });
755
- const scales = computeScales(barSpec, chartArea, barSpec.data);
756
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
757
-
758
- expect(axes.x!.tickAngle).toBe(-45);
759
- });
760
- });
761
-
762
- // ---------------------------------------------------------------------------
763
- // Axis config expansion tests
764
- // ---------------------------------------------------------------------------
765
-
766
- describe('axis config properties', () => {
767
- it('uses title instead of deprecated label', () => {
768
- const spec: NormalizedChartSpec = {
769
- ...lineSpec,
770
- encoding: {
771
- x: { field: 'date', type: 'temporal', axis: { title: 'Year' } },
772
- y: { field: 'value', type: 'quantitative', axis: { title: 'Amount ($)' } },
773
- },
774
- };
775
- const scales = computeScales(spec, chartArea, spec.data);
776
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
777
-
778
- expect(axes.x!.label).toBe('Year');
779
- expect(axes.y!.label).toBe('Amount ($)');
780
- });
781
-
782
- it('propagates labelAngle to layout tickAngle', () => {
783
- const spec: NormalizedChartSpec = {
784
- ...lineSpec,
785
- encoding: {
786
- x: {
787
- field: 'date',
788
- type: 'temporal',
789
- axis: { labelAngle: -30 },
790
- },
791
- y: { field: 'value', type: 'quantitative' },
792
- },
793
- };
794
- const scales = computeScales(spec, chartArea, spec.data);
795
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
796
-
797
- expect(axes.x!.tickAngle).toBe(-30);
798
- });
799
-
800
- it('passes orient config to axis layout', () => {
801
- const spec: NormalizedChartSpec = {
802
- ...lineSpec,
803
- encoding: {
804
- x: { field: 'date', type: 'temporal', axis: { orient: 'top' } },
805
- y: { field: 'value', type: 'quantitative', axis: { orient: 'right' } },
806
- },
807
- };
808
- const scales = computeScales(spec, chartArea, spec.data);
809
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
810
-
811
- expect(axes.x!.orient).toBe('top');
812
- expect(axes.y!.orient).toBe('right');
813
- });
814
-
815
- it('passes domain and ticks visibility to axis layout', () => {
816
- const spec: NormalizedChartSpec = {
817
- ...lineSpec,
818
- encoding: {
819
- x: { field: 'date', type: 'temporal', axis: { domain: false, ticks: false } },
820
- y: { field: 'value', type: 'quantitative' },
821
- },
822
- };
823
- const scales = computeScales(spec, chartArea, spec.data);
824
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
825
-
826
- expect(axes.x!.domainLine).toBe(false);
827
- expect(axes.x!.tickMarks).toBe(false);
828
- });
829
-
830
- it('passes offset and padding configs to axis layout', () => {
831
- const spec: NormalizedChartSpec = {
832
- ...lineSpec,
833
- encoding: {
834
- x: {
835
- field: 'date',
836
- type: 'temporal',
837
- axis: { offset: 10, titlePadding: 8, labelPadding: 4 },
838
- },
839
- y: { field: 'value', type: 'quantitative' },
840
- },
841
- };
842
- const scales = computeScales(spec, chartArea, spec.data);
843
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
844
-
845
- expect(axes.x!.offset).toBe(10);
846
- expect(axes.x!.titlePadding).toBe(8);
847
- expect(axes.x!.labelPadding).toBe(4);
848
- });
849
-
850
- it('passes labelOverlap and labelFlush to axis layout', () => {
851
- const spec: NormalizedChartSpec = {
852
- ...lineSpec,
853
- encoding: {
854
- x: {
855
- field: 'date',
856
- type: 'temporal',
857
- axis: { labelOverlap: 'parity', labelFlush: true },
858
- },
859
- y: { field: 'value', type: 'quantitative' },
860
- },
861
- };
862
- const scales = computeScales(spec, chartArea, spec.data);
863
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
864
-
865
- expect(axes.x!.labelOverlap).toBe('parity');
866
- expect(axes.x!.labelFlush).toBe(true);
867
- });
868
-
869
- it('uses explicit tick values when provided', () => {
870
- const spec: NormalizedChartSpec = {
871
- ...lineSpec,
872
- encoding: {
873
- x: { field: 'date', type: 'temporal' },
874
- y: {
875
- field: 'value',
876
- type: 'quantitative',
877
- axis: { values: [0, 250, 500] },
878
- },
879
- },
880
- };
881
- const scales = computeScales(spec, chartArea, spec.data);
882
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
883
-
884
- // Should produce exactly 3 ticks matching our explicit values
885
- expect(axes.y!.ticks.length).toBe(3);
886
- expect(axes.y!.ticks[0].value).toBe(0);
887
- expect(axes.y!.ticks[1].value).toBe(250);
888
- expect(axes.y!.ticks[2].value).toBe(500);
889
- });
890
-
891
- it('defaults are undefined when axis config properties are not set', () => {
892
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
893
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
894
-
895
- // All new properties should be undefined when not set (except domainLine,
896
- // which defaults to false for non-grounded marks like line)
897
- expect(axes.x!.orient).toBeUndefined();
898
- expect(axes.x!.domainLine).toBe(false);
899
- expect(axes.x!.tickMarks).toBeUndefined();
900
- expect(axes.x!.offset).toBeUndefined();
901
- expect(axes.x!.titlePadding).toBeUndefined();
902
- expect(axes.x!.labelPadding).toBeUndefined();
903
- expect(axes.x!.labelOverlap).toBeUndefined();
904
- expect(axes.x!.labelFlush).toBeUndefined();
905
- });
906
- });
907
-
908
- // ---------------------------------------------------------------------------
909
- // Y-axis produces ~5 ticks at standard sizes
910
- // ---------------------------------------------------------------------------
911
-
912
- describe('y-axis tick density', () => {
913
- it('produces 5+ y-axis ticks at standard chart size with full density', () => {
914
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
915
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
916
-
917
- // A 500x300 chart with domain [100, 500] should show ~5+ ticks, not just 2
918
- expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
919
- });
920
-
921
- it('y-axis thinning uses vertical overlap, not horizontal text width', () => {
922
- // Even with a measureText that reports very wide labels, y-axis should
923
- // not thin aggressively because overlap is checked vertically
924
- const wideMeasure = () => ({ width: 500, height: 12 });
925
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
926
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, wideMeasure);
927
-
928
- // Wide label text shouldn't cause y-axis thinning (only height matters)
929
- expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(5);
930
- });
931
-
932
- it('does not collapse to min/max when nice() domain creates a close last tick', () => {
933
- // Regression: domain [0, 340] nice()'s to [0, 350]. The old thinning path
934
- // kept both 340 and the endpoint 350, causing cascaded thinning down to
935
- // [0, 340] (2 ticks). Fix re-requests at lower counts from D3 instead.
936
- const barSpec: NormalizedChartSpec = {
937
- markType: 'bar',
938
- markDef: { type: 'bar' },
939
- data: [
940
- { year: '2019', v: 110 },
941
- { year: '2023', v: 340 },
942
- ],
943
- encoding: {
944
- x: { field: 'year', type: 'nominal' },
945
- y: { field: 'v', type: 'quantitative' },
946
- },
947
- chrome: {},
948
- annotations: [],
949
- responsive: true,
950
- theme: {},
951
- darkMode: 'off',
952
- labels: { density: 'auto', format: '' },
953
- };
954
- const scales = computeScales(barSpec, chartArea, barSpec.data);
955
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
956
-
957
- // The specific bug produced exactly [0, 340] — only the min and max with
958
- // no interior gridlines. Any output with interior values is a pass, even
959
- // if the count varies with D3's step choice across platforms.
960
- const values = axes.y!.ticks.map((t) => t.value);
961
- expect(values).not.toEqual([0, 340]);
962
- expect(values).not.toEqual([0, 350]);
963
- expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(4);
964
- });
965
-
966
- it('adapts y-axis tick count down for short charts', () => {
967
- const shortArea = { x: 50, y: 50, width: 500, height: 140 };
968
- const scales = computeScales(lineSpec, shortArea, lineSpec.data);
969
- const axes = computeAxes(scales, shortArea, fullStrategy, theme);
970
-
971
- // Short charts should still show multiple ticks but fewer than tall ones
972
- expect(axes.y!.ticks.length).toBeGreaterThanOrEqual(2);
973
- expect(axes.y!.ticks.length).toBeLessThan(10);
974
- });
975
-
976
- it('steps continuous x-axis down when D3 overshoots on narrow temporal scales', () => {
977
- // D3 time scales jump between calendar units — a request for 6 ticks on
978
- // a 3-year range can return 4 (yearly) or 14 (quarterly). On a narrow
979
- // chart we want the sparser choice, not the dense one.
980
- const narrowTimeSpec: NormalizedChartSpec = {
981
- ...lineSpec,
982
- data: [
983
- { date: '2022-01-01', value: 10 },
984
- { date: '2022-12-01', value: 40 },
985
- ],
986
- };
987
- const narrowArea = { x: 50, y: 50, width: 300, height: 300 };
988
- const scales = computeScales(narrowTimeSpec, narrowArea, narrowTimeSpec.data);
989
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
990
-
991
- // A 300px-wide time axis should show at most ~6 labels, not the 10-14
992
- // that D3 produces when its nice() step hops into monthly territory.
993
- expect(axes.x!.ticks.length).toBeLessThanOrEqual(6);
994
- expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
995
- });
996
- });
997
-
998
- // ---------------------------------------------------------------------------
999
- // Vertical orientation overlap detection
1000
- // ---------------------------------------------------------------------------
1001
-
1002
- describe('ticksOverlap with vertical orientation', () => {
1003
- const fontSize = 12;
1004
- const fontWeight = 400;
1005
-
1006
- it('returns false when vertical ticks have sufficient spacing', () => {
1007
- // Labels at 30px intervals with 12px font (14.4px with lineHeight)
1008
- const ticks: AxisTick[] = [
1009
- { value: 0, position: 0, label: '100' },
1010
- { value: 1, position: 30, label: '200' },
1011
- { value: 2, position: 60, label: '300' },
1012
- { value: 3, position: 90, label: '400' },
1013
- { value: 4, position: 120, label: '500' },
1014
- ];
1015
- expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
1016
- });
1017
-
1018
- it('returns true when vertical ticks are too close', () => {
1019
- // Labels at 10px intervals with 12px font - should overlap vertically
1020
- const ticks: AxisTick[] = [
1021
- { value: 0, position: 0, label: '100' },
1022
- { value: 1, position: 10, label: '200' },
1023
- { value: 2, position: 20, label: '300' },
1024
- ];
1025
- expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(true);
1026
- });
1027
-
1028
- it('ignores label text width for vertical orientation', () => {
1029
- // Very wide labels but well-spaced vertically - should NOT overlap
1030
- const ticks: AxisTick[] = [
1031
- { value: 0, position: 0, label: 'Very Long Label Text Here' },
1032
- { value: 1, position: 40, label: 'Another Very Long Label' },
1033
- { value: 2, position: 80, label: 'Yet Another Long Label' },
1034
- ];
1035
- // Horizontal would detect overlap, vertical should not
1036
- expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'horizontal')).toBe(true);
1037
- expect(ticksOverlap(ticks, fontSize, fontWeight, undefined, 'vertical')).toBe(false);
1038
- });
1039
- });
1040
-
1041
- // ---------------------------------------------------------------------------
1042
- // Horizontal bar chart: y-axis category label regression
1043
- // Mobile/compact viewports must show all category labels on horizontal bar
1044
- // charts, regardless of axisLabelDensity. Thinning is only valid on x-axis
1045
- // band scales where many category names can overlap horizontally.
1046
- // ---------------------------------------------------------------------------
1047
-
1048
- describe('horizontal bar y-axis label thinning regression', () => {
1049
- const countries = [
1050
- 'USA',
1051
- 'Germany',
1052
- 'France',
1053
- 'Japan',
1054
- 'UK',
1055
- 'Canada',
1056
- 'Australia',
1057
- 'Netherlands',
1058
- 'Sweden',
1059
- 'Switzerland',
1060
- ];
1061
-
1062
- const hBarSpec: NormalizedChartSpec = {
1063
- markType: 'bar',
1064
- markDef: { type: 'bar', orient: 'horizontal' },
1065
- data: countries.map((country, i) => ({ country, value: (i + 1) * 100 })),
1066
- encoding: {
1067
- x: { field: 'value', type: 'quantitative' },
1068
- y: { field: 'country', type: 'nominal' },
1069
- },
1070
- chrome: {},
1071
- annotations: [],
1072
- responsive: true,
1073
- theme: {},
1074
- darkMode: 'off',
1075
- labels: { density: 'auto', format: '' },
1076
- };
1077
-
1078
- it('shows all category labels on y-axis at minimal density (mobile regression)', () => {
1079
- const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
1080
- const axes = computeAxes(scales, chartArea, minimalStrategy, theme);
1081
-
1082
- // Every bar must have a label -- thinning to 3 on mobile was the bug
1083
- expect(axes.y!.ticks.length).toBe(countries.length);
1084
- });
1085
-
1086
- it('shows all category labels on y-axis at reduced density', () => {
1087
- const reducedStrategy: LayoutStrategy = {
1088
- ...minimalStrategy,
1089
- axisLabelDensity: 'reduced',
1090
- };
1091
- const scales = computeScales(hBarSpec, chartArea, hBarSpec.data);
1092
- const axes = computeAxes(scales, chartArea, reducedStrategy, theme);
1093
-
1094
- expect(axes.y!.ticks.length).toBe(countries.length);
1095
- });
1096
-
1097
- it('rotates to vertical instead of thinning when diagonals would touch (column chart)', () => {
1098
- const vBarSpec: NormalizedChartSpec = {
1099
- ...hBarSpec,
1100
- markDef: { type: 'bar', orient: 'vertical' },
1101
- encoding: {
1102
- x: { field: 'country', type: 'nominal' },
1103
- y: { field: 'value', type: 'quantitative' },
1104
- },
1105
- };
1106
- const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
1107
- const scales = computeScales(vBarSpec, narrowArea, vBarSpec.data);
1108
- const axes = computeAxes(scales, narrowArea, minimalStrategy, theme);
1109
-
1110
- // 10 categories at ~19px steps: -45° ribbons would touch (19·sin45 ≈ 14px
1111
- // < one line height), so the ladder escalates to vertical, where the 19px
1112
- // step separation fits — every bar keeps its label.
1113
- expect(axes.x!.tickAngle).toBe(-90);
1114
- expect(axes.x!.ticks.length).toBe(countries.length);
1115
- });
1116
- });
1117
-
1118
- // ---------------------------------------------------------------------------
1119
- // Log scale tick filtering — buildContinuousTicks
1120
- // D3 log scales ignore the count hint and return ticks at every sub-power
1121
- // position. The engine must filter these down to powers of the base only.
1122
- // ---------------------------------------------------------------------------
1123
-
1124
- /**
1125
- * Build a ResolvedScale backed by a D3 log scale, matching what buildLogScale produces.
1126
- * Using a single `base` parameter keeps the D3 scale and channel config in sync,
1127
- * mirroring how they're always derived from the same spec field.
1128
- */
1129
- function makeLogScale(domain: [number, number], base = 10): ResolvedScale {
1130
- const scale = scaleLog().domain(domain).range([400, 0]);
1131
- scale.base(base);
1132
- return {
1133
- scale,
1134
- type: 'log',
1135
- channel: {
1136
- field: 'value',
1137
- type: 'quantitative',
1138
- scale: base !== 10 ? { base } : undefined,
1139
- },
1140
- } as ResolvedScale;
1141
- }
1142
-
1143
- describe('buildContinuousTicks — log scale power filtering', () => {
1144
- it('returns only power-of-10 ticks for [5, 25000] at tickCount 5', () => {
1145
- const resolved = makeLogScale([5, 25000]);
1146
- const ticks = buildContinuousTicks(resolved, 5);
1147
- const values = ticks.map((t) => t.value as number);
1148
- // Should be exactly the powers of 10 in domain: 10, 100, 1000, 10000
1149
- expect(values).toEqual([10, 100, 1000, 10000]);
1150
- });
1151
-
1152
- it('returns only power-of-10 ticks for [1, 1000000] at tickCount 5', () => {
1153
- const resolved = makeLogScale([1, 1_000_000]);
1154
- const ticks = buildContinuousTicks(resolved, 5);
1155
- const values = ticks.map((t) => t.value as number);
1156
- // Assert invariants rather than exact output: every tick is a power of 10,
1157
- // and we get at least 5 (the domain spans 6 decades).
1158
- for (const v of values) {
1159
- const exp = Math.log10(v);
1160
- expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-9);
1161
- }
1162
- expect(values.length).toBeGreaterThanOrEqual(5);
1163
- });
1164
-
1165
- it('returns only powers-of-2 for [1, 64] base-2 at tickCount 5', () => {
1166
- const resolved = makeLogScale([1, 64], 2);
1167
- const ticks = buildContinuousTicks(resolved, 5);
1168
- const values = ticks.map((t) => t.value as number);
1169
- expect(values).toEqual([1, 2, 4, 8, 16, 32, 64]);
1170
- });
1171
-
1172
- it('handles fractional powers for [0.001, 100] at tickCount 5', () => {
1173
- const resolved = makeLogScale([0.001, 100]);
1174
- const ticks = buildContinuousTicks(resolved, 5);
1175
- const values = ticks.map((t) => t.value as number);
1176
- // Tolerance check prevents floating-point false negatives on 0.001, 0.01, 0.1
1177
- expect(values).toEqual([0.001, 0.01, 0.1, 1, 10, 100]);
1178
- });
1179
-
1180
- it('still produces ticks at tickCount 3 (regression guard)', () => {
1181
- const resolved = makeLogScale([1, 1000]);
1182
- const ticks = buildContinuousTicks(resolved, 3);
1183
- expect(ticks.length).toBeGreaterThanOrEqual(2);
1184
- const values = ticks.map((t) => t.value as number);
1185
- // Every value must be a power of 10
1186
- for (const v of values) {
1187
- const exp = Math.log10(v);
1188
- expect(Math.abs(exp - Math.round(exp))).toBeLessThan(1e-6);
1189
- }
1190
- });
1191
-
1192
- it('does not over-filter linear scales with the same domain', () => {
1193
- const scale = scaleLinear().domain([5, 25000]).range([400, 0]);
1194
- const resolved: ResolvedScale = {
1195
- scale,
1196
- type: 'linear',
1197
- channel: { field: 'value', type: 'quantitative' },
1198
- } as ResolvedScale;
1199
- const ticks = buildContinuousTicks(resolved, 5);
1200
- // Linear scale should return normal D3 ticks — not just power-of-10 values
1201
- expect(ticks.length).toBeGreaterThanOrEqual(4);
1202
- // At least one tick should NOT be a power of 10 (e.g. 5000, 10000, 15000, 20000, 25000)
1203
- const values = ticks.map((t) => t.value as number);
1204
- const nonPowerOf10 = values.filter((v) => {
1205
- if (v <= 0) return true;
1206
- const exp = Math.log10(v);
1207
- return Math.abs(exp - Math.round(exp)) >= 0.01;
1208
- });
1209
- expect(nonPowerOf10.length).toBeGreaterThan(0);
1210
- });
1211
- });
1212
-
1213
- describe('axis tickPosition', () => {
1214
- it('defaults y-axis to inline for line charts', () => {
1215
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
1216
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1217
- data: lineSpec.data,
1218
- encoding: lineSpec.encoding,
1219
- markType: 'line',
1220
- });
1221
-
1222
- expect(axes.y!.tickPosition).toBe('inline');
1223
- expect(axes.y!.domainLine).toBe(false);
1224
- expect(axes.y!.tickMarks).toBe(false);
1225
- expect(axes.x!.tickPosition).toBe('gutter');
1226
- });
1227
-
1228
- it('defaults y-axis to inline for area charts', () => {
1229
- const areaSpec: NormalizedChartSpec = {
1230
- ...lineSpec,
1231
- markType: 'area',
1232
- markDef: { type: 'area' },
1233
- };
1234
- const scales = computeScales(areaSpec, chartArea, areaSpec.data);
1235
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1236
- data: areaSpec.data,
1237
- encoding: areaSpec.encoding,
1238
- markType: 'area',
1239
- });
1240
-
1241
- expect(axes.y!.tickPosition).toBe('inline');
1242
- });
1243
-
1244
- it('defaults y-axis to gutter when markType is unknown', () => {
1245
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
1246
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
1247
-
1248
- expect(axes.y!.tickPosition).toBe('gutter');
1249
- });
1250
-
1251
- it('respects explicit user override on y-axis', () => {
1252
- const overrideSpec: NormalizedChartSpec = {
1253
- ...lineSpec,
1254
- encoding: {
1255
- ...lineSpec.encoding,
1256
- y: { ...lineSpec.encoding.y!, axis: { tickPosition: 'gutter' } },
1257
- },
1258
- };
1259
- const scales = computeScales(overrideSpec, chartArea, overrideSpec.data);
1260
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1261
- data: overrideSpec.data,
1262
- encoding: overrideSpec.encoding,
1263
- markType: 'line',
1264
- });
1265
-
1266
- expect(axes.y!.tickPosition).toBe('gutter');
1267
- });
1268
-
1269
- it('right-side y-axis stays gutter even on line charts', () => {
1270
- const dualSpec: NormalizedChartSpec = {
1271
- ...lineSpec,
1272
- encoding: {
1273
- ...lineSpec.encoding,
1274
- y: { ...lineSpec.encoding.y!, axis: { orient: 'right' } },
1275
- },
1276
- };
1277
- const scales = computeScales(dualSpec, chartArea, dualSpec.data);
1278
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1279
- data: dualSpec.data,
1280
- encoding: dualSpec.encoding,
1281
- markType: 'line',
1282
- });
1283
-
1284
- expect(axes.y!.tickPosition).toBe('gutter');
1285
- });
1286
- });
1287
-
1288
- describe('axis extent and titlePosition', () => {
1289
- it('emits x-axis extent for a basic line chart', () => {
1290
- const scales = computeScales(lineSpec, chartArea, lineSpec.data);
1291
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
1292
-
1293
- expect(axes.x!.extent).toBeGreaterThan(0);
1294
- expect(axes.x!.extent).toBeLessThan(200);
1295
- });
1296
-
1297
- it('extent is larger when axis has a title', () => {
1298
- const specWithTitle: NormalizedChartSpec = {
1299
- ...lineSpec,
1300
- encoding: {
1301
- ...lineSpec.encoding,
1302
- x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
1303
- },
1304
- };
1305
- const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
1306
- const axesWithTitle = computeAxes(scales, chartArea, fullStrategy, theme);
1307
-
1308
- const scalesNoTitle = computeScales(lineSpec, chartArea, lineSpec.data);
1309
- const axesNoTitle = computeAxes(scalesNoTitle, chartArea, fullStrategy, theme);
1310
-
1311
- expect(axesWithTitle.x!.extent).toBeGreaterThan(axesNoTitle.x!.extent!);
1312
- });
1313
-
1314
- it('emits titlePosition for x-axis with title', () => {
1315
- const specWithTitle: NormalizedChartSpec = {
1316
- ...lineSpec,
1317
- encoding: {
1318
- ...lineSpec.encoding,
1319
- x: { ...lineSpec.encoding.x, axis: { title: 'Year' } },
1320
- },
1321
- };
1322
- const scales = computeScales(specWithTitle, chartArea, specWithTitle.data);
1323
- const axes = computeAxes(scales, chartArea, fullStrategy, theme);
1324
-
1325
- expect(axes.x!.titlePosition).toBeDefined();
1326
- expect(axes.x!.titlePosition!.x).toBeCloseTo(chartArea.x + chartArea.width / 2, 0);
1327
- expect(axes.x!.titlePosition!.y).toBeGreaterThan(chartArea.y + chartArea.height);
1328
- });
1329
-
1330
- it('emits titlePosition for y-axis with title', () => {
1331
- const specWithYTitle: NormalizedChartSpec = {
1332
- ...lineSpec,
1333
- encoding: {
1334
- ...lineSpec.encoding,
1335
- y: { ...lineSpec.encoding.y, axis: { title: 'Value' } },
1336
- },
1337
- };
1338
- const scales = computeScales(specWithYTitle, chartArea, specWithYTitle.data);
1339
- const axes = computeAxes(scales, chartArea, fullStrategy, theme, undefined, {
1340
- data: specWithYTitle.data,
1341
- encoding: specWithYTitle.encoding as Encoding,
1342
- skipX: false,
1343
- skipY: false,
1344
- markType: 'line',
1345
- totalWidth: 600,
1346
- });
1347
-
1348
- expect(axes.y!.titlePosition).toBeDefined();
1349
- expect(axes.y!.titlePosition!.x).toBeLessThan(chartArea.x);
1350
- expect(axes.y!.titlePosition!.angle).toBe(-90);
1351
- });
1352
- });
1353
-
1354
- describe('quantitative axis minimum tick floor', () => {
1355
- // A tight domain like [73, 88] makes D3's `scale.ticks(2)` collapse to a
1356
- // single nice value ([80]). Before the floor, this rendered as a lone tick
1357
- // (a real iPhone blog chart showed only "80"). The floor must hold even at
1358
- // very narrow widths where the target count drops to 2.
1359
- const tightDomainSpec: NormalizedChartSpec = {
1360
- markType: 'bar',
1361
- markDef: { type: 'bar', orient: 'horizontal' },
1362
- data: [
1363
- { district: 'A', score: 79 },
1364
- { district: 'B', score: 73 },
1365
- { district: 'C', score: 88 },
1366
- ],
1367
- encoding: {
1368
- y: { field: 'district', type: 'nominal' },
1369
- x: {
1370
- field: 'score',
1371
- type: 'quantitative',
1372
- axis: { format: '.0f', grid: true },
1373
- scale: { domain: [73, 88], nice: false },
1374
- },
1375
- },
1376
- chrome: {},
1377
- annotations: [],
1378
- responsive: true,
1379
- theme: {},
1380
- darkMode: 'off',
1381
- labels: { density: 'all', format: '.0f' },
1382
- };
1383
-
1384
- it('renders >= 2 ticks at a very narrow (320px-ish) chart width', () => {
1385
- // Narrow chart area forces the minimal density target count down to 2,
1386
- // where D3 collapses [73, 88] to the single tick "80" without the floor.
1387
- const narrowArea = { x: 90, y: 50, width: 90, height: 180 };
1388
- const scales = computeScales(tightDomainSpec, narrowArea, tightDomainSpec.data);
1389
- const axes = computeAxes(scales, narrowArea, minimalStrategy, theme, undefined, {
1390
- data: tightDomainSpec.data,
1391
- encoding: tightDomainSpec.encoding as Encoding,
1392
- markType: 'bar',
1393
- });
1394
-
1395
- expect(axes.x).toBeDefined();
1396
- expect(axes.x!.ticks.length).toBeGreaterThanOrEqual(2);
1397
- });
1398
-
1399
- it('holds the 2-tick floor across a sweep of narrow widths', () => {
1400
- for (const width of [60, 90, 120, 220, 320]) {
1401
- const area = { x: 90, y: 50, width, height: 180 };
1402
- const scales = computeScales(tightDomainSpec, area, tightDomainSpec.data);
1403
- const axes = computeAxes(scales, area, minimalStrategy, theme, undefined, {
1404
- data: tightDomainSpec.data,
1405
- encoding: tightDomainSpec.encoding as Encoding,
1406
- markType: 'bar',
1407
- });
1408
- expect(
1409
- axes.x!.ticks.length,
1410
- `expected >= 2 ticks at width ${width}, got ${axes.x!.ticks.length}`,
1411
- ).toBeGreaterThanOrEqual(2);
1412
- }
1413
- });
1414
- });
1415
-
1416
- // ---------------------------------------------------------------------------
1417
- // Compact temporal tick formats: pinned pre-change behavior
1418
- // ---------------------------------------------------------------------------
1419
- // These two tests pin the exact tick labels produced BEFORE the compact
1420
- // temporal format rung lands in fitContinuousTicks. They must stay green
1421
- // through that change:
1422
- // - wide desktop axes have room for full-format labels, so compact never fires
1423
- // - temporal y ticks are never compact (planner/renderer gutter agreement;
1424
- // see plans/compact-temporal-ticks.md)
1425
-
1426
- describe('compact temporal ticks: pinned pre-change output', () => {
1427
- const monthlyData = Array.from({ length: 12 }, (_, i) => ({
1428
- date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1429
- value: 100 + i * 10,
1430
- }));
1431
-
1432
- const monthlySpec: NormalizedChartSpec = {
1433
- ...lineSpec,
1434
- data: monthlyData,
1435
- };
1436
-
1437
- it('desktop unchanged: monthly temporal x at ~900px keeps full-format labels', () => {
1438
- const wideArea = { x: 50, y: 50, width: 900, height: 300 };
1439
- const scales = computeScales(monthlySpec, wideArea, monthlySpec.data);
1440
- const axes = computeAxes(scales, wideArea, fullStrategy, theme);
1441
-
1442
- expect(axes.x!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1443
- });
1444
-
1445
- it('temporal y-axis unaffected: labels at ~360px stay full-format', () => {
1446
- const ySpec: NormalizedChartSpec = {
1447
- ...monthlySpec,
1448
- markType: 'point',
1449
- markDef: { type: 'point' },
1450
- encoding: {
1451
- x: { field: 'value', type: 'quantitative' },
1452
- y: { field: 'date', type: 'temporal' },
1453
- },
1454
- };
1455
- const narrowArea = { x: 40, y: 40, width: 360, height: 300 };
1456
- const scales = computeScales(ySpec, narrowArea, ySpec.data);
1457
- const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
1458
-
1459
- expect(axes.y!.ticks.map((t) => t.label)).toEqual(['2025', 'Apr 2025', 'Jul 2025', 'Oct 2025']);
1460
- });
1461
-
1462
- it('temporal y-axis stays full-format even at starved heights (fallback path)', () => {
1463
- // Regression guard for the fallback path: compact is x-axis-only (the
1464
- // planner/renderer y pair must format identically), so even a height so
1465
- // short that fitContinuousTicks falls through to thinning must keep the
1466
- // year on every label — never a bare compact month like 'Oct'.
1467
- const ySpec: NormalizedChartSpec = {
1468
- ...monthlySpec,
1469
- markType: 'point',
1470
- markDef: { type: 'point' },
1471
- encoding: {
1472
- x: { field: 'value', type: 'quantitative' },
1473
- y: { field: 'date', type: 'temporal' },
1474
- },
1475
- };
1476
- const starvedArea = { x: 40, y: 40, width: 360, height: 55 };
1477
- const scales = computeScales(ySpec, starvedArea, ySpec.data);
1478
- const axes = computeAxes(scales, starvedArea, fullStrategy, theme);
1479
-
1480
- const labels = axes.y!.ticks.map((t) => t.label);
1481
- expect(labels.length).toBeGreaterThanOrEqual(2);
1482
- for (const label of labels) {
1483
- expect(label).toMatch(/\d{4}/);
1484
- }
1485
- });
1486
- });
1487
-
1488
- // ---------------------------------------------------------------------------
1489
- // Compact temporal tick formats: the fitting rung
1490
- // ---------------------------------------------------------------------------
1491
- // When full-format labels overlap on a temporal x-axis, fitContinuousTicks
1492
- // tries the compact format table at the SAME count before dropping ticks.
1493
- // Pre-change counts below were measured by running these exact inputs against
1494
- // the unmodified engine (git stash) — see plans/compact-temporal-ticks.md.
1495
-
1496
- describe('compact temporal ticks: fitting rung', () => {
1497
- const monthlySpecOver = (
1498
- months: number,
1499
- axis?: Record<string, unknown>,
1500
- ): NormalizedChartSpec => ({
1501
- ...lineSpec,
1502
- data: Array.from({ length: months }, (_, i) => ({
1503
- date: `2025-${String(i + 1).padStart(2, '0')}-01`,
1504
- value: 100 + i * 10,
1505
- })),
1506
- encoding: {
1507
- x: { field: 'date', type: 'temporal', ...(axis ? { axis } : {}) },
1508
- y: { field: 'value', type: 'quantitative' },
1509
- },
1510
- });
1511
-
1512
- const xLabelsAt = (spec: NormalizedChartSpec, width: number): string[] => {
1513
- const area = { x: 40, y: 40, width, height: 300 };
1514
- const scales = computeScales(spec, area, spec.data);
1515
- const axes = computeAxes(scales, area, fullStrategy, theme);
1516
- return axes.x!.ticks.map((t) => t.label);
1517
- };
1518
-
1519
- const COMPACT_MONTH = /^[A-Z][a-z]{2}$/; // 'Feb'
1520
- const FULL_MONTH = /^[A-Z][a-z]{2} \d{4}$/; // 'Feb 2025'
1521
- const YEAR_ONLY = /^\d{4}$/; // year-boundary tick, same in both tables
1522
-
1523
- it('keeps more ticks than pre-change when full labels overlap (Jan-Dec 2025 at 160px)', () => {
1524
- const labels = xLabelsAt(monthlySpecOver(12), 160);
1525
-
1526
- // Pre-change output at this width: ['2025', 'Oct 2025'] — the overlap
1527
- // forced a count step-down to 2. Compact keeps all 4 quarterly ticks.
1528
- expect(labels).toEqual(['2025', 'Apr', 'Jul', 'Oct']);
1529
- expect(labels.length).toBeGreaterThan(2);
1530
-
1531
- // No label carries a year except year-boundary ticks.
1532
- for (const label of labels) {
1533
- if (/\d{4}/.test(label)) expect(label).toMatch(YEAR_ONLY);
1534
- }
1535
- });
1536
-
1537
- it('renders bare compact months at monthly tick granularity (Jan-Apr 2025 at 160px)', () => {
1538
- // End the domain mid-April: a domain ending exactly on Apr 1 includes the
1539
- // Apr tick only when the runner's timezone is UTC (local time scales place
1540
- // month boundaries at local midnight, so negative offsets push Apr 1 past
1541
- // the domain end).
1542
- const base = monthlySpecOver(4);
1543
- const spec = { ...base, data: [...base.data, { date: '2025-04-15', value: 145 }] };
1544
- const labels = xLabelsAt(spec, 160);
1545
-
1546
- // Pre-change output at this width: ['2025', 'Mar 2025'] (2 ticks).
1547
- expect(labels).toEqual(['2025', 'Feb', 'Mar', 'Apr']);
1548
- expect(labels).toContain('Feb');
1549
- });
1550
-
1551
- it('refit stays internally consistent when D3 overshoots the requested count', () => {
1552
- // Jan-May 2025: D3 returns 5 monthly ticks for a request of 3 (> the 1.5x
1553
- // overshoot tolerance), forcing the refit loop. Whatever count/format the
1554
- // loop lands on, the returned set must be ONE format family — never a mix
1555
- // of 'Feb' and 'Mar 2025'.
1556
- const spec = monthlySpecOver(5);
1557
- for (const width of [100, 140, 180, 240]) {
1558
- const labels = xLabelsAt(spec, width);
1559
- const monthLabels = labels.filter((l) => !YEAR_ONLY.test(l));
1560
- expect(monthLabels.length, `width ${width}`).toBeGreaterThan(0);
1561
- const allCompact = monthLabels.every((l) => COMPACT_MONTH.test(l));
1562
- const allFull = monthLabels.every((l) => FULL_MONTH.test(l));
1563
- expect(
1564
- allCompact || allFull,
1565
- `mixed format families at width ${width}: ${JSON.stringify(labels)}`,
1566
- ).toBe(true);
1567
- }
1568
- });
1569
-
1570
- it('explicit axis.format wins over compact at narrow width', () => {
1571
- const labels = xLabelsAt(monthlySpecOver(12, { format: '%Y-%m' }), 160);
1572
-
1573
- expect(labels.length).toBeGreaterThan(0);
1574
- for (const label of labels) {
1575
- expect(label).toMatch(/^\d{4}-\d{2}$/);
1576
- }
1577
- expect(labels).toEqual(['2025-01', '2025-10']);
1578
- });
1579
- });