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

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 (160) hide show
  1. package/dist/index.d.ts +124 -14
  2. package/dist/index.js +16254 -9417
  3. package/dist/index.js.map +1 -1
  4. package/package.json +11 -4
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +232 -56
  6. package/src/__tests__/annotation-behavior.test.ts +852 -0
  7. package/src/__tests__/axes.test.ts +50 -3
  8. package/src/__tests__/compile-chart.test.ts +30 -0
  9. package/src/__tests__/compile-footnotes.test.ts +277 -0
  10. package/src/__tests__/compile-layer.test.ts +120 -1
  11. package/src/__tests__/facet.test.ts +144 -0
  12. package/src/__tests__/helpers/invariants.ts +54 -20
  13. package/src/__tests__/known-bugs.test.ts +455 -2
  14. package/src/__tests__/legend.test.ts +69 -4
  15. package/src/__tests__/ranking-recipes.test.ts +260 -0
  16. package/src/__tests__/scales.test.ts +9 -3
  17. package/src/__tests__/series-search.test.ts +92 -0
  18. package/src/__tests__/spec-sugar.test.ts +801 -0
  19. package/src/__tests__/you-draw-it.test.ts +160 -0
  20. package/src/annotations/__tests__/compute.test.ts +207 -92
  21. package/src/annotations/__tests__/geometry.test.ts +66 -0
  22. package/src/annotations/__tests__/resolve-text.test.ts +527 -27
  23. package/src/annotations/__tests__/rich-text.test.ts +91 -0
  24. package/src/annotations/__tests__/thinning.test.ts +148 -0
  25. package/src/annotations/collisions.ts +130 -84
  26. package/src/annotations/compute.ts +83 -85
  27. package/src/annotations/constants.ts +116 -6
  28. package/src/annotations/geometry.ts +228 -77
  29. package/src/annotations/move.ts +135 -0
  30. package/src/annotations/placement.ts +62 -14
  31. package/src/annotations/resolve-range.ts +2 -0
  32. package/src/annotations/resolve-refline.ts +2 -0
  33. package/src/annotations/resolve-text.ts +318 -111
  34. package/src/annotations/rich-text.ts +65 -0
  35. package/src/annotations/thinning.ts +84 -10
  36. package/src/barlist/compile-barlist.ts +16 -6
  37. package/src/charts/_shared/format-label-value.ts +9 -8
  38. package/src/charts/bar/__tests__/compute.test.ts +57 -10
  39. package/src/charts/bar/__tests__/labels.test.ts +5 -4
  40. package/src/charts/bar/compute.ts +2 -6
  41. package/src/charts/bar/index.ts +8 -1
  42. package/src/charts/bar/labels.ts +3 -3
  43. package/src/charts/beeswarm/__tests__/compute.test.ts +280 -0
  44. package/src/charts/beeswarm/__tests__/dodge.test.ts +148 -0
  45. package/src/charts/beeswarm/compute.ts +203 -0
  46. package/src/charts/beeswarm/dodge.ts +155 -0
  47. package/src/charts/beeswarm/index.ts +32 -0
  48. package/src/charts/builtin.ts +12 -1
  49. package/src/charts/calendar/__tests__/compute.test.ts +417 -0
  50. package/src/charts/calendar/compute.ts +319 -0
  51. package/src/charts/calendar/index.ts +32 -0
  52. package/src/charts/column/__tests__/compute.test.ts +21 -7
  53. package/src/charts/column/__tests__/labels.test.ts +76 -1
  54. package/src/charts/column/compute.ts +2 -6
  55. package/src/charts/column/index.ts +10 -2
  56. package/src/charts/column/labels.ts +95 -21
  57. package/src/charts/dot/index.ts +8 -1
  58. package/src/charts/dot/labels.ts +3 -3
  59. package/src/charts/line/__tests__/compute.test.ts +15 -19
  60. package/src/charts/line/area.ts +8 -23
  61. package/src/charts/line/compute.ts +32 -2
  62. package/src/charts/parliament/__tests__/compute.test.ts +411 -0
  63. package/src/charts/parliament/compute.ts +313 -0
  64. package/src/charts/parliament/index.ts +32 -0
  65. package/src/charts/pie/__tests__/compute.test.ts +103 -0
  66. package/src/charts/pie/compute.ts +63 -8
  67. package/src/charts/pie/labels.ts +1 -0
  68. package/src/charts/range/__tests__/compute.test.ts +486 -0
  69. package/src/charts/range/compute.ts +273 -0
  70. package/src/charts/range/index.ts +82 -0
  71. package/src/charts/range/labels.ts +169 -0
  72. package/src/charts/rect/__tests__/compute.test.ts +120 -0
  73. package/src/charts/rect/index.ts +127 -0
  74. package/src/charts/scatter/compute.ts +7 -21
  75. package/src/charts/text/__tests__/compute.test.ts +307 -0
  76. package/src/charts/text/index.ts +44 -7
  77. package/src/charts/tick/__tests__/compute.test.ts +75 -0
  78. package/src/charts/tick/index.ts +7 -4
  79. package/src/charts/utils.ts +5 -3
  80. package/src/charts/waffle/__tests__/compute.test.ts +305 -0
  81. package/src/charts/waffle/compute.ts +197 -0
  82. package/src/charts/waffle/index.ts +31 -0
  83. package/src/compile/__tests__/color-scale-reverse.test.ts +89 -0
  84. package/src/compile/__tests__/data-clip.test.ts +68 -0
  85. package/src/compile/__tests__/fill-patterns.test.ts +154 -0
  86. package/src/compile/__tests__/size-scale.test.ts +108 -0
  87. package/src/compile/color-scale-range.ts +54 -33
  88. package/src/compile/data-clip.ts +21 -6
  89. package/src/compile/fill-patterns.ts +74 -0
  90. package/src/compile/layer.ts +121 -21
  91. package/src/compile/size-scale.ts +141 -0
  92. package/src/compile/spec-sugar.ts +767 -0
  93. package/src/compile/you-draw-it.ts +123 -0
  94. package/src/compile.ts +415 -110
  95. package/src/compiler/__tests__/a11y-warnings.test.ts +148 -0
  96. package/src/compiler/__tests__/fixtures/vl-parity/axis-null.json +19 -0
  97. package/src/compiler/__tests__/fixtures/vl-parity/color-value.json +20 -0
  98. package/src/compiler/__tests__/fixtures/vl-parity/count-aggregate.json +22 -0
  99. package/src/compiler/__tests__/fixtures/vl-parity/data-values.json +19 -0
  100. package/src/compiler/__tests__/fixtures/vl-parity/layer-axis-null.json +14 -0
  101. package/src/compiler/__tests__/fixtures/vl-parity/layer-count.json +34 -0
  102. package/src/compiler/__tests__/fixtures/vl-parity/layer-data-values.json +22 -0
  103. package/src/compiler/__tests__/fixtures/vl-parity/layer-legend-null.json +41 -0
  104. package/src/compiler/__tests__/fixtures/vl-parity/layer-scheme.json +38 -0
  105. package/src/compiler/__tests__/fixtures/vl-parity/layer-sort.json +28 -0
  106. package/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +28 -0
  107. package/src/compiler/__tests__/fixtures/vl-parity/layer-title.json +30 -0
  108. package/src/compiler/__tests__/fixtures/vl-parity/layer-value-def.json +29 -0
  109. package/src/compiler/__tests__/fixtures/vl-parity/legend-null.json +32 -0
  110. package/src/compiler/__tests__/fixtures/vl-parity/scheme.json +25 -0
  111. package/src/compiler/__tests__/fixtures/vl-parity/sort-by-value.json +19 -0
  112. package/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +19 -0
  113. package/src/compiler/__tests__/fixtures/vl-parity/title-subtitle.json +21 -0
  114. package/src/compiler/__tests__/fixtures/vl-parity/width-height.json +23 -0
  115. package/src/compiler/__tests__/keys.test.ts +3 -9
  116. package/src/compiler/__tests__/normalize.test.ts +65 -1
  117. package/src/compiler/__tests__/validate-did-you-mean.test.ts +170 -0
  118. package/src/compiler/__tests__/validate-near-miss.test.ts +251 -0
  119. package/src/compiler/__tests__/validate.test.ts +258 -0
  120. package/src/compiler/__tests__/vl-parity.test.ts +68 -0
  121. package/src/compiler/a11y-warnings.ts +110 -0
  122. package/src/compiler/keys.ts +5 -4
  123. package/src/compiler/normalize.ts +151 -7
  124. package/src/compiler/types.ts +11 -0
  125. package/src/compiler/validate.ts +961 -32
  126. package/src/endpoint-labels/__tests__/compute.test.ts +88 -0
  127. package/src/endpoint-labels/compute.ts +40 -12
  128. package/src/endpoint-labels/format.ts +45 -15
  129. package/src/endpoint-labels/predict.ts +50 -36
  130. package/src/format/__tests__/field-format.test.ts +164 -0
  131. package/src/format/field-format.ts +27 -0
  132. package/src/geo/__tests__/compile-map.test.ts +1128 -0
  133. package/src/geo/compile-map.ts +1376 -0
  134. package/src/geo/join.ts +115 -0
  135. package/src/geo/projections.ts +65 -0
  136. package/src/geo/types.ts +26 -0
  137. package/src/graphs/__tests__/compile-graph.test.ts +25 -0
  138. package/src/graphs/compile-graph.ts +27 -18
  139. package/src/index.ts +20 -2
  140. package/src/layout/axes/ticks.ts +59 -10
  141. package/src/layout/axes.ts +35 -6
  142. package/src/layout/dimensions.ts +139 -83
  143. package/src/layout/facet.ts +14 -3
  144. package/src/layout/plan.ts +83 -33
  145. package/src/layout/scales.ts +132 -15
  146. package/src/legend/__tests__/continuous.test.ts +318 -0
  147. package/src/legend/__tests__/size.test.ts +348 -0
  148. package/src/legend/__tests__/suppression.test.ts +17 -1
  149. package/src/legend/compute.ts +123 -16
  150. package/src/legend/continuous.ts +327 -0
  151. package/src/legend/size.ts +407 -0
  152. package/src/legend/suppression.ts +8 -7
  153. package/src/sankey/compile-sankey.ts +31 -19
  154. package/src/tables/format-cells.ts +16 -7
  155. package/src/tilemap/__tests__/compile-tilemap.test.ts +39 -0
  156. package/src/tilemap/compile-tilemap.ts +24 -7
  157. package/src/tooltips/__tests__/compute.test.ts +2 -2
  158. package/src/tooltips/compute.ts +422 -41
  159. package/src/transforms/__tests__/conditional.test.ts +18 -0
  160. package/src/transforms/aggregate.ts +2 -1
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Rect mark renderer (heatmap cells).
3
+ *
4
+ * The Vega-Lite `rect` mark: one cell per row, positioned by x and y, colored
5
+ * by the `color` channel. The canonical use is a two-way heatmap (nominal x
6
+ * nominal, quantitative color), which is what this renderer is built for.
7
+ *
8
+ * `rect` used to alias `columnRenderer`, which hard-requires a band x-scale and
9
+ * a *linear* y-scale (it anchors every cell to `yScale(0)`). A heatmap has a
10
+ * band scale on both axes, so `columnRenderer` bailed at its bandwidth guard and
11
+ * emitted nothing: `mark: 'rect'` rendered a blank chart, with no error.
12
+ *
13
+ * Not to be confused with `RectMark`, the layout primitive that columns, waffle
14
+ * cells, calendar cells, and dot stems all emit. That was never broken; only the
15
+ * spec-level `mark: 'rect'` was.
16
+ */
17
+
18
+ import type { Encoding, Mark, MarkAria, Rect, RectMark } from '@opendata-ai/openchart-core';
19
+ import { formatNumber } from '@opendata-ai/openchart-core';
20
+ import type { ScaleBand } from 'd3-scale';
21
+
22
+ import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
23
+ import type { NormalizedChartSpec } from '../../compiler/types';
24
+ import type { ResolvedScales } from '../../layout/scales';
25
+ import type { ChartRenderer } from '../registry';
26
+ import { getColor, getSequentialColor, scaleValue } from '../utils';
27
+
28
+ /**
29
+ * Cell extent along one axis. `resolveScale` gives `rect` a band scale on both
30
+ * axes (see the `chartType === 'rect'` branch in layout/scales.ts), so the
31
+ * bandwidth *is* the cell size. A non-band scale means a quantitative axis,
32
+ * which would need binning to have a cell width at all: return 0 so the caller
33
+ * can bail rather than emit degenerate zero-area cells.
34
+ */
35
+ function bandwidthOf(scale: ResolvedScales['x']): number {
36
+ const band = scale?.scale as Partial<ScaleBand<string>> | undefined;
37
+ return typeof band?.bandwidth === 'function' ? band.bandwidth() : 0;
38
+ }
39
+
40
+ /**
41
+ * Compute rect (heatmap cell) marks from a normalized spec and resolved scales.
42
+ *
43
+ * Both axes are band scales, so a cell's x/y is the scaled value directly (a
44
+ * band places the cell's leading corner there) and its size is the bandwidth.
45
+ */
46
+ export function computeRectMarks(
47
+ spec: NormalizedChartSpec,
48
+ scales: ResolvedScales,
49
+ _chartArea: Rect,
50
+ ): RectMark[] {
51
+ const encoding = spec.encoding as Encoding;
52
+ const xChannel = encoding.x;
53
+ const yChannel = encoding.y;
54
+
55
+ if (!xChannel || !yChannel || !scales.x || !scales.y) return [];
56
+
57
+ const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
58
+ const isSequential = colorEnc?.type === 'quantitative';
59
+
60
+ const cellWidth = bandwidthOf(scales.x);
61
+ const cellHeight = bandwidthOf(scales.y);
62
+ // A quantitative axis yields no bandwidth. Cells would be zero-area and
63
+ // invisible, so emit nothing rather than pretend: `rect` wants two
64
+ // categorical axes (bin the field first to heatmap a continuous one).
65
+ if (cellWidth <= 0 || cellHeight <= 0) return [];
66
+
67
+ const marks: RectMark[] = [];
68
+
69
+ for (const row of spec.data) {
70
+ const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
71
+ const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
72
+ if (xVal == null || yVal == null) continue;
73
+
74
+ // A quantitative color channel drives a sequential/binned ramp (the heatmap
75
+ // case); a nominal one picks a categorical swatch.
76
+ let fill: RectMark['fill'];
77
+ if (colorEnc && isSequential) {
78
+ const value = Number(row[colorEnc.field]);
79
+ if (!Number.isFinite(value)) continue;
80
+ fill = getSequentialColor(scales, value);
81
+ } else if (colorEnc) {
82
+ fill = getColor(scales, String(row[colorEnc.field] ?? '__default__'));
83
+ } else {
84
+ fill = getColor(scales, '__default__');
85
+ }
86
+
87
+ const colorValue = colorEnc ? row[colorEnc.field] : undefined;
88
+ const colorLabel =
89
+ colorEnc && colorValue != null
90
+ ? `, ${colorEnc.field}: ${
91
+ typeof colorValue === 'number' ? formatNumber(colorValue) : String(colorValue)
92
+ }`
93
+ : '';
94
+ const aria: MarkAria = {
95
+ label: `${row[xChannel.field]}, ${row[yChannel.field]}${colorLabel}`,
96
+ };
97
+
98
+ marks.push({
99
+ type: 'rect',
100
+ x: xVal,
101
+ y: yVal,
102
+ width: cellWidth,
103
+ height: cellHeight,
104
+ fill,
105
+ data: row as Record<string, unknown>,
106
+ aria,
107
+ });
108
+ }
109
+
110
+ // Key on the cell's coordinates: the x/y pair is what identifies a cell across
111
+ // a data update, which is what the transition driver matches on.
112
+ const rawKeys = marks.map(
113
+ (m) =>
114
+ `${serializeKeyValue(m.data[xChannel.field])}|${serializeKeyValue(m.data[yChannel.field])}`,
115
+ );
116
+ const keys = dedupeKeys(rawKeys);
117
+ for (let i = 0; i < marks.length; i++) {
118
+ marks[i].key = keys[i];
119
+ }
120
+
121
+ return marks;
122
+ }
123
+
124
+ /** Rect (heatmap) chart renderer. */
125
+ export const rectRenderer: ChartRenderer = (spec, scales, chartArea, _strategy, _theme) => {
126
+ return computeRectMarks(spec, scales, chartArea) as Mark[];
127
+ };
@@ -16,10 +16,8 @@ import type {
16
16
  PointMark,
17
17
  Rect,
18
18
  } from '@opendata-ai/openchart-core';
19
- import { max, min } from 'd3-array';
20
19
  import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
21
- import { scaleSqrt } from 'd3-scale';
22
-
20
+ import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
23
21
  import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
24
22
  import type { NormalizedChartSpec } from '../../compiler/types';
25
23
  import type { ResolvedScales } from '../../layout/scales';
@@ -30,8 +28,6 @@ import { getColor, getSequentialColor } from '../utils';
30
28
  // ---------------------------------------------------------------------------
31
29
 
32
30
  const DEFAULT_POINT_RADIUS = 5;
33
- const MIN_BUBBLE_RADIUS = 3;
34
- const MAX_BUBBLE_RADIUS = 30;
35
31
 
36
32
  // ---------------------------------------------------------------------------
37
33
  // Helpers
@@ -117,22 +113,12 @@ export function computeScatterMarks(
117
113
  const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
118
114
  const sizeField = sizeEnc?.field;
119
115
 
120
- // Build a size scale for bubble variant. Domain and radius range are
121
- // author-overridable via `encoding.size.scale.{domain,range}`: an explicit
122
- // domain (e.g. [0, 900]) keeps the largest datum below the max radius so
123
- // dense clusters don't blob together, and an explicit range caps the radii.
124
- let sizeScale: ((v: number) => number) | undefined;
125
- if (sizeField) {
126
- const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));
127
-
128
- const explicitDomain = sizeEnc?.scale?.domain as [number, number] | undefined;
129
- const [sizeMin, sizeMax] = explicitDomain ?? [min(sizeValues) ?? 0, max(sizeValues) ?? 1];
130
-
131
- const explicitRange = sizeEnc?.scale?.range as [number, number] | undefined;
132
- const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];
133
-
134
- sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
135
- }
116
+ // Bubbles: sqrt so perceived magnitude tracks disc *area*. Author overrides
117
+ // (`encoding.size.scale.{domain,range}`) and the degenerate-domain fallback
118
+ // live in the shared builder, which the size legend also calls so the key and
119
+ // the marks can never resolve different scales.
120
+ const resolvedSize = buildSizeScale(sizeEnc, spec.data, SIZE_SCALE_DEFAULTS.point);
121
+ const sizeScale = resolvedSize?.scale;
136
122
 
137
123
  const keyEnc = encoding.key && 'field' in encoding.key ? encoding.key : undefined;
138
124
  const keyField = keyEnc?.field;
@@ -0,0 +1,307 @@
1
+ import type { ChartSpec, LayerSpec, PointMark, TextMarkLayout } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compileChart, compileLayer } from '../../../compile';
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Shared fixtures
7
+ // ---------------------------------------------------------------------------
8
+
9
+ /**
10
+ * Ten US states, GDP (trillions) vs population (millions). The low end clusters
11
+ * tightly, which is what made the scale-domain bug so visible: the further a
12
+ * point sat from the shared upper bound, the further its label drifted.
13
+ */
14
+ const states = [
15
+ { label: 'CA', gdp: 3.9, pop: 39.0 },
16
+ { label: 'TX', gdp: 2.6, pop: 30.5 },
17
+ { label: 'NY', gdp: 2.1, pop: 19.6 },
18
+ { label: 'FL', gdp: 1.6, pop: 22.6 },
19
+ { label: 'IL', gdp: 1.1, pop: 12.5 },
20
+ { label: 'PA', gdp: 1.0, pop: 12.9 },
21
+ { label: 'OH', gdp: 0.9, pop: 11.8 },
22
+ { label: 'GA', gdp: 0.8, pop: 11.0 },
23
+ { label: 'NJ', gdp: 0.8, pop: 9.3 },
24
+ { label: 'WA', gdp: 0.8, pop: 7.8 },
25
+ ];
26
+
27
+ /** A points layer plus a text layer, both encoding the *same* x/y fields. */
28
+ function labeledScatter(textMark: ChartSpec['mark'] = { type: 'text' }): LayerSpec {
29
+ return {
30
+ layer: [
31
+ {
32
+ mark: { type: 'point' },
33
+ data: [...states],
34
+ encoding: {
35
+ x: { field: 'gdp', type: 'quantitative' },
36
+ y: { field: 'pop', type: 'quantitative' },
37
+ },
38
+ },
39
+ {
40
+ mark: textMark,
41
+ data: [...states],
42
+ encoding: {
43
+ x: { field: 'gdp', type: 'quantitative' },
44
+ y: { field: 'pop', type: 'quantitative' },
45
+ text: { field: 'label', type: 'nominal' },
46
+ },
47
+ },
48
+ ],
49
+ };
50
+ }
51
+
52
+ function textMarksOf(spec: ChartSpec): TextMarkLayout[] {
53
+ const layout = compileChart(spec, { width: 900, height: 500 });
54
+ return layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
55
+ }
56
+
57
+ /** Standalone text chart over the states, with an overridable mark def. */
58
+ function textChart(mark: ChartSpec['mark'] = { type: 'text' }): ChartSpec {
59
+ return {
60
+ mark,
61
+ data: [...states],
62
+ encoding: {
63
+ x: { field: 'gdp', type: 'quantitative' },
64
+ y: { field: 'pop', type: 'quantitative' },
65
+ text: { field: 'label', type: 'nominal' },
66
+ },
67
+ };
68
+ }
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Scale alignment
72
+ // ---------------------------------------------------------------------------
73
+
74
+ describe('text marks in a layer', () => {
75
+ /**
76
+ * Regression: text is a position-encoding mark, so it must resolve its domain
77
+ * the same way `point`/`beeswarm`/`range` do (tight, not zero-anchored). When
78
+ * it didn't, a text layer and a point layer over identical fields resolved
79
+ * *different* domains across the same pixel range, and every label drifted off
80
+ * the dot it was labeling — by up to 160px at the low end of the domain.
81
+ */
82
+ it('positions each label exactly on the point it labels', () => {
83
+ const layout = compileLayer(labeledScatter(), { width: 900, height: 500 });
84
+
85
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
86
+ const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
87
+
88
+ expect(points).toHaveLength(states.length);
89
+ expect(texts).toHaveLength(states.length);
90
+
91
+ for (const text of texts) {
92
+ const point = points.find((p) => p.data?.label === text.text);
93
+ expect(point, `no point for label ${text.text}`).toBeDefined();
94
+
95
+ expect(text.x).toBeCloseTo((point as PointMark).cx, 5);
96
+ expect(text.y).toBeCloseTo((point as PointMark).cy, 5);
97
+ }
98
+ });
99
+
100
+ it('offsets labels off their points by exactly dy, keeping x aligned', () => {
101
+ const layout = compileLayer(labeledScatter({ type: 'text', dy: -10 }), {
102
+ width: 900,
103
+ height: 500,
104
+ });
105
+
106
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
107
+ const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
108
+
109
+ for (const text of texts) {
110
+ const point = points.find((p) => p.data?.label === text.text) as PointMark;
111
+ expect(text.x).toBeCloseTo(point.cx, 5);
112
+ expect(text.y).toBeCloseTo(point.cy - 10, 5);
113
+ }
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Offsets
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('dx / dy', () => {
122
+ it('bakes the offset into x/y and retains the pre-offset anchor', () => {
123
+ const plain = textMarksOf(textChart());
124
+ const offset = textMarksOf(textChart({ type: 'text', dx: 6, dy: -10 }));
125
+
126
+ for (let i = 0; i < plain.length; i++) {
127
+ expect(offset[i].x).toBeCloseTo(plain[i].x + 6, 5);
128
+ expect(offset[i].y).toBeCloseTo(plain[i].y - 10, 5);
129
+ // The anchor is what a leader line would point back to.
130
+ expect(offset[i].anchorX).toBeCloseTo(plain[i].x, 5);
131
+ expect(offset[i].anchorY).toBeCloseTo(plain[i].y, 5);
132
+ }
133
+ });
134
+
135
+ it('leaves the anchor unset when the mark is not offset', () => {
136
+ for (const mark of textMarksOf(textChart())) {
137
+ expect(mark.anchorX).toBeUndefined();
138
+ expect(mark.anchorY).toBeUndefined();
139
+ }
140
+ });
141
+ });
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Alignment / baseline
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('align and baseline', () => {
148
+ it('centers on the data point by default', () => {
149
+ for (const mark of textMarksOf(textChart())) {
150
+ expect(mark.textAnchor).toBe('middle');
151
+ expect(mark.dominantBaseline).toBe('central');
152
+ }
153
+ });
154
+
155
+ it('maps align/baseline onto SVG anchor and baseline', () => {
156
+ const marks = textMarksOf(textChart({ type: 'text', align: 'left', baseline: 'top' }));
157
+ for (const mark of marks) {
158
+ expect(mark.textAnchor).toBe('start');
159
+ expect(mark.dominantBaseline).toBe('hanging');
160
+ }
161
+ });
162
+ });
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Size channel
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('size channel', () => {
169
+ const sized = (): ChartSpec => ({
170
+ mark: { type: 'text' },
171
+ data: [...states],
172
+ encoding: {
173
+ x: { field: 'gdp', type: 'quantitative' },
174
+ y: { field: 'pop', type: 'quantitative' },
175
+ text: { field: 'label', type: 'nominal' },
176
+ size: { field: 'gdp', type: 'quantitative' },
177
+ },
178
+ });
179
+
180
+ it('maps the size field linearly onto the default font range', () => {
181
+ const marks = textMarksOf(sized());
182
+ const byLabel = new Map(marks.map((m) => [m.text, m]));
183
+
184
+ // gdp spans 0.8 (WA) to 3.9 (CA) -> the default 11..32px range.
185
+ expect(byLabel.get('WA')?.fontSize).toBeCloseTo(11, 5);
186
+ expect(byLabel.get('CA')?.fontSize).toBeCloseTo(32, 5);
187
+
188
+ // Linear, not sqrt: interior values interpolate proportionally. A sqrt scale
189
+ // would land NY well above this.
190
+ const ny = byLabel.get('NY') as TextMarkLayout; // gdp 2.1
191
+ const expectedNy = 11 + (32 - 11) * ((2.1 - 0.8) / (3.9 - 0.8));
192
+ expect(ny.fontSize).toBeCloseTo(expectedNy, 5);
193
+ });
194
+
195
+ it('honors an explicit scale range', () => {
196
+ const spec = sized();
197
+ (spec.encoding.size as { scale?: unknown }).scale = { range: [10, 20] };
198
+
199
+ const marks = textMarksOf(spec);
200
+ const byLabel = new Map(marks.map((m) => [m.text, m]));
201
+ expect(byLabel.get('WA')?.fontSize).toBeCloseTo(10, 5);
202
+ expect(byLabel.get('CA')?.fontSize).toBeCloseTo(20, 5);
203
+ });
204
+
205
+ it('falls back to mark.fontSize when there is no size encoding', () => {
206
+ for (const mark of textMarksOf(textChart({ type: 'text', fontSize: 14 }))) {
207
+ expect(mark.fontSize).toBe(14);
208
+ }
209
+ });
210
+
211
+ it('defaults to 12px with no size encoding and no fontSize', () => {
212
+ for (const mark of textMarksOf(textChart())) {
213
+ expect(mark.fontSize).toBe(12);
214
+ }
215
+ });
216
+ });
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Subset label layers
220
+ // ---------------------------------------------------------------------------
221
+
222
+ describe('a label layer over a subset of the points', () => {
223
+ /**
224
+ * Direct labeling usually names only the notable points, so the text layer
225
+ * carries fewer rows than the point layer. The two must still resolve the same
226
+ * domain — a subset must not re-fit the scale to its own narrower extent.
227
+ */
228
+ it('keeps subset labels aligned with the full point layer', () => {
229
+ const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL', 'WA']);
230
+ const spec: LayerSpec = {
231
+ layer: [
232
+ {
233
+ mark: { type: 'point' },
234
+ data: [...states],
235
+ encoding: {
236
+ x: { field: 'gdp', type: 'quantitative' },
237
+ y: { field: 'pop', type: 'quantitative' },
238
+ },
239
+ },
240
+ {
241
+ mark: { type: 'text', dy: -14 },
242
+ data: states.filter((d) => labeled.has(d.label)),
243
+ encoding: {
244
+ x: { field: 'gdp', type: 'quantitative' },
245
+ y: { field: 'pop', type: 'quantitative' },
246
+ text: { field: 'label', type: 'nominal' },
247
+ },
248
+ },
249
+ ],
250
+ };
251
+
252
+ const layout = compileLayer(spec, { width: 900, height: 500 });
253
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
254
+ const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
255
+
256
+ expect(points).toHaveLength(states.length);
257
+ expect(texts).toHaveLength(labeled.size);
258
+
259
+ for (const text of texts) {
260
+ const point = points.find((p) => p.data?.label === text.text) as PointMark;
261
+ expect(text.x).toBeCloseTo(point.cx, 5);
262
+ expect(text.y).toBeCloseTo(point.cy - 14, 5);
263
+ }
264
+ });
265
+ });
266
+
267
+ describe('a subset whose extent is narrower than the point layer', () => {
268
+ /**
269
+ * Dropping the min-x row from the label layer narrows *its* data extent. If
270
+ * each leaf resolves its own domain, the label layer re-fits [1.1, 3.9] while
271
+ * the points keep [0.8, 3.9], and every label slides left off its dot.
272
+ */
273
+ it('still aligns when the label subset omits the extreme point', () => {
274
+ const labeled = new Set(['CA', 'TX', 'NY', 'FL', 'IL']); // no WA (the min gdp)
275
+ const spec: LayerSpec = {
276
+ layer: [
277
+ {
278
+ mark: { type: 'point' },
279
+ data: [...states],
280
+ encoding: {
281
+ x: { field: 'gdp', type: 'quantitative' },
282
+ y: { field: 'pop', type: 'quantitative' },
283
+ },
284
+ },
285
+ {
286
+ mark: { type: 'text' },
287
+ data: states.filter((d) => labeled.has(d.label)),
288
+ encoding: {
289
+ x: { field: 'gdp', type: 'quantitative' },
290
+ y: { field: 'pop', type: 'quantitative' },
291
+ text: { field: 'label', type: 'nominal' },
292
+ },
293
+ },
294
+ ],
295
+ };
296
+
297
+ const layout = compileLayer(spec, { width: 900, height: 500 });
298
+ const points = layout.marks.filter((m): m is PointMark => m.type === 'point');
299
+ const texts = layout.marks.filter((m): m is TextMarkLayout => m.type === 'textMark');
300
+
301
+ for (const text of texts) {
302
+ const point = points.find((p) => p.data?.label === text.text) as PointMark;
303
+ expect(text.x, `label ${text.text} drifted in x`).toBeCloseTo(point.cx, 5);
304
+ expect(text.y, `label ${text.text} drifted in y`).toBeCloseTo(point.cy, 5);
305
+ }
306
+ });
307
+ });
@@ -8,13 +8,28 @@
8
8
 
9
9
  import type { Encoding, Mark, MarkAria, TextMarkLayout } from '@opendata-ai/openchart-core';
10
10
  import { getRepresentativeColor } from '@opendata-ai/openchart-core';
11
-
11
+ import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../../compile/size-scale';
12
12
  import { dedupeKeys, serializeKeyValue } from '../../compiler/keys';
13
13
  import type { NormalizedChartSpec } from '../../compiler/types';
14
14
  import type { ResolvedScales } from '../../layout/scales';
15
15
  import type { ChartRenderer } from '../registry';
16
16
  import { getColor, scaleValue } from '../utils';
17
17
 
18
+ /** Font size for text marks with no `size` encoding and no `mark.fontSize`. */
19
+ const DEFAULT_FONT_SIZE = 12;
20
+
21
+ const ALIGN_TO_ANCHOR = {
22
+ left: 'start',
23
+ center: 'middle',
24
+ right: 'end',
25
+ } as const satisfies Record<string, TextMarkLayout['textAnchor']>;
26
+
27
+ const BASELINE_TO_DOMINANT = {
28
+ top: 'hanging',
29
+ middle: 'central',
30
+ bottom: 'text-after-edge',
31
+ } as const satisfies Record<string, NonNullable<TextMarkLayout['dominantBaseline']>>;
32
+
18
33
  /**
19
34
  * Compute text marks from spec data and resolved scales.
20
35
  */
@@ -34,6 +49,22 @@ export function computeTextMarks(
34
49
  const colorField = colorEncoding?.field;
35
50
  const sizeEncoding = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
36
51
 
52
+ const markDef = spec.markDef ?? {};
53
+ const dx = markDef.dx ?? 0;
54
+ const dy = markDef.dy ?? 0;
55
+ const isOffset = dx !== 0 || dy !== 0;
56
+ const textAnchor = ALIGN_TO_ANCHOR[markDef.align ?? 'center'];
57
+ const dominantBaseline = BASELINE_TO_DOMINANT[markDef.baseline ?? 'middle'];
58
+
59
+ // Map the `size` field onto a font-size range. Linear, not sqrt: sqrt is right
60
+ // for bubbles because perceived magnitude tracks *area* (∝ r²), but a glyph
61
+ // reads by its height, which is linear in font size.
62
+ //
63
+ // Unlike scatter/beeswarm, this channel is *not* keyed: the library ships no
64
+ // font-size legend, and no publication uses one. Treat it as emphasis, not as
65
+ // a channel a reader is expected to decode back into a value.
66
+ const fontSizeScale = buildSizeScale(sizeEncoding, spec.data, SIZE_SCALE_DEFAULTS.text)?.scale;
67
+
37
68
  for (const row of spec.data) {
38
69
  // Resolve x position (center of chart if no x encoding)
39
70
  let x = 0;
@@ -60,22 +91,28 @@ export function computeTextMarks(
60
91
  : getColor(scales, '__default__'),
61
92
  );
62
93
 
63
- const fontSize = sizeEncoding
64
- ? Math.max(8, Math.min(48, Number(row[sizeEncoding.field]) || 12))
65
- : 12;
94
+ const fallbackFontSize = markDef.fontSize ?? DEFAULT_FONT_SIZE;
95
+ const sizeValue = sizeEncoding ? Number(row[sizeEncoding.field]) : Number.NaN;
96
+ const fontSize =
97
+ fontSizeScale && Number.isFinite(sizeValue) ? fontSizeScale(sizeValue) : fallbackFontSize;
66
98
 
67
99
  const aria: MarkAria = {
68
100
  label: text,
69
101
  };
70
102
 
103
+ // Bake the offset into x/y rather than emitting SVG dx/dy: the transition
104
+ // driver tweens the x/y attributes and rotation pivots on them, so a
105
+ // separate offset attribute would leave both reading the un-offset anchor.
71
106
  marks.push({
72
107
  type: 'textMark',
73
- x,
74
- y,
108
+ x: x + dx,
109
+ y: y + dy,
110
+ ...(isOffset ? { anchorX: x, anchorY: y } : {}),
75
111
  text,
76
112
  fill: color,
77
113
  fontSize,
78
- textAnchor: 'middle',
114
+ textAnchor,
115
+ dominantBaseline,
79
116
  angle:
80
117
  encoding.angle && 'field' in encoding.angle
81
118
  ? Number(row[encoding.angle.field]) || 0
@@ -0,0 +1,75 @@
1
+ import type { TickMarkLayout } from '@opendata-ai/openchart-core';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { compileChart } from '../../../compile';
4
+
5
+ const OPTIONS = { width: 800, height: 500 };
6
+
7
+ const ROWS = [
8
+ { group: '20-29', hours: 3.1 },
9
+ { group: '20-29', hours: 4.7 },
10
+ { group: '60+', hours: 4.1 },
11
+ { group: '60+', hours: 7.0 },
12
+ ];
13
+
14
+ function tickMarks(layout: ReturnType<typeof compileChart>): TickMarkLayout[] {
15
+ return layout.marks.filter((m): m is TickMarkLayout => m.type === 'tick');
16
+ }
17
+
18
+ describe('tick marks', () => {
19
+ it('draws vertical strokes when the value axis is x', () => {
20
+ const layout = compileChart(
21
+ {
22
+ mark: 'tick',
23
+ data: ROWS,
24
+ encoding: {
25
+ x: { field: 'hours', type: 'quantitative' },
26
+ y: { field: 'group', type: 'nominal' },
27
+ },
28
+ },
29
+ OPTIONS,
30
+ );
31
+
32
+ const marks = tickMarks(layout);
33
+ expect(marks).toHaveLength(ROWS.length);
34
+ // Perpendicular to the quantitative axis: values run left-to-right, so each
35
+ // observation is a vertical stroke. Horizontal strokes would lie along the
36
+ // value axis and butt end-to-end into a solid bar.
37
+ expect(marks.every((m) => m.orient === 'vertical')).toBe(true);
38
+ });
39
+
40
+ it('draws horizontal strokes when the value axis is y', () => {
41
+ const layout = compileChart(
42
+ {
43
+ mark: 'tick',
44
+ data: ROWS,
45
+ encoding: {
46
+ x: { field: 'group', type: 'nominal' },
47
+ y: { field: 'hours', type: 'quantitative' },
48
+ },
49
+ },
50
+ OPTIONS,
51
+ );
52
+
53
+ expect(tickMarks(layout).every((m) => m.orient === 'horizontal')).toBe(true);
54
+ });
55
+
56
+ it('separates observations within a category along the value axis', () => {
57
+ const layout = compileChart(
58
+ {
59
+ mark: 'tick',
60
+ data: ROWS,
61
+ encoding: {
62
+ x: { field: 'hours', type: 'quantitative' },
63
+ y: { field: 'group', type: 'nominal' },
64
+ },
65
+ },
66
+ OPTIONS,
67
+ );
68
+
69
+ const young = tickMarks(layout).filter((m) => m.data.group === '20-29');
70
+ expect(young).toHaveLength(2);
71
+ // Same row, different x: the strip plot's entire job.
72
+ expect(young[0].y).toBe(young[1].y);
73
+ expect(young[0].x).not.toBe(young[1].x);
74
+ });
75
+ });
@@ -41,9 +41,12 @@ export function computeTickMarks(
41
41
  const colorField = colorEncoding?.field;
42
42
  const marks: TickMarkLayout[] = [];
43
43
 
44
- // Determine orientation: ticks are perpendicular to the quantitative axis
45
- const isHorizontal = xChannel.type === 'quantitative' && yChannel.type !== 'quantitative';
46
- const orient: 'horizontal' | 'vertical' = isHorizontal ? 'horizontal' : 'vertical';
44
+ // Ticks run perpendicular to the quantitative axis: a quantitative x (values
45
+ // spread left-to-right along a categorical row) gets vertical strokes, and a
46
+ // quantitative y gets horizontal ones. Drawing them parallel instead makes
47
+ // neighbouring observations butt end-to-end and read as one continuous bar.
48
+ const isQuantX = xChannel.type === 'quantitative' && yChannel.type !== 'quantitative';
49
+ const orient: 'horizontal' | 'vertical' = isQuantX ? 'vertical' : 'horizontal';
47
50
 
48
51
  for (const row of spec.data) {
49
52
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
@@ -78,7 +81,7 @@ export function computeTickMarks(
78
81
  }
79
82
 
80
83
  // Stamp keys: position-defining value, fall back to index
81
- const posField = isHorizontal ? xChannel.field : yChannel.field;
84
+ const posField = isQuantX ? xChannel.field : yChannel.field;
82
85
  const rawKeys = marks.map((m) => serializeKeyValue(m.data[posField]));
83
86
  const keys = dedupeKeys(rawKeys);
84
87
  for (let i = 0; i < marks.length; i++) {