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

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