@opendata-ai/openchart-engine 7.2.4 → 7.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/index.d.ts +88 -39
  2. package/dist/index.js +4134 -2930
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +143 -78
  6. package/src/__tests__/axes.test.ts +129 -1
  7. package/src/__tests__/compile-chart.test.ts +6 -0
  8. package/src/__tests__/compile-layer.test.ts +49 -0
  9. package/src/__tests__/dimensions.test.ts +70 -2
  10. package/src/__tests__/helpers/invariants.ts +265 -0
  11. package/src/__tests__/known-bugs.test.ts +113 -0
  12. package/src/__tests__/layout-fuzz.test.ts +358 -0
  13. package/src/__tests__/layout-invariants.test.ts +535 -0
  14. package/src/__tests__/legend.test.ts +37 -0
  15. package/src/annotations/__tests__/compute.test.ts +8 -15
  16. package/src/annotations/__tests__/placement-fuzz.test.ts +256 -0
  17. package/src/annotations/__tests__/placement-invariants.test.ts +345 -0
  18. package/src/annotations/__tests__/placement.test.ts +316 -0
  19. package/src/annotations/collisions.ts +19 -7
  20. package/src/annotations/compute.ts +257 -16
  21. package/src/annotations/geometry.ts +73 -15
  22. package/src/annotations/placement.ts +588 -0
  23. package/src/annotations/resolve-text.ts +58 -10
  24. package/src/charts/__tests__/post-process.test.ts +3 -3
  25. package/src/charts/bar/__tests__/compute.test.ts +131 -45
  26. package/src/charts/bar/compute.ts +44 -4
  27. package/src/charts/bar/index.ts +9 -2
  28. package/src/charts/bar/labels.ts +77 -26
  29. package/src/charts/column/labels.ts +29 -16
  30. package/src/charts/post-process.ts +21 -7
  31. package/src/charts/registry.ts +4 -2
  32. package/src/compile/layer.ts +31 -9
  33. package/src/compile.ts +88 -149
  34. package/src/index.ts +9 -0
  35. package/src/layout/axes/rotation.ts +36 -0
  36. package/src/layout/axes.ts +130 -8
  37. package/src/layout/dimensions.ts +80 -98
  38. package/src/layout/plan.ts +550 -0
  39. package/src/layout/scales.ts +22 -0
  40. package/src/layout/shared.ts +44 -0
  41. package/src/legend/compute.ts +315 -78
  42. package/src/legend/wrap.ts +11 -7
  43. package/src/sankey/__tests__/node-label-wrap.test.ts +2 -2
  44. package/src/tables/__tests__/heatmap.test.ts +24 -20
  45. package/src/tables/heatmap.ts +25 -9
@@ -17,7 +17,7 @@ import type {
17
17
  Rect,
18
18
  RectMark,
19
19
  } from '@opendata-ai/openchart-core';
20
- import { isGradientDef } from '@opendata-ai/openchart-core';
20
+ import { isGradientDef, NARROW_VIEWPORT_MAX } from '@opendata-ai/openchart-core';
21
21
  import type { ScaleBand, ScaleLinear } from 'd3-scale';
22
22
  import type { NormalizedChartSpec } from '../../compiler/types';
23
23
  import type { ResolvedScales } from '../../layout/scales';
@@ -54,6 +54,16 @@ function orientGradientForHorizontalBar(grad: GradientDef): GradientDef {
54
54
 
55
55
  const MIN_BAR_WIDTH = 1;
56
56
 
57
+ /**
58
+ * Readable floor (px) for a single grouped-bar thickness. Value labels render at
59
+ * fontSize 10 with ~12px line height; below this a bar is thinner than its own
60
+ * label and the chart reads as stripes (observed on iOS at 375-430px). When
61
+ * grouped sub-bars would fall under this, we reclaim the band scale's reserved
62
+ * inter-category whitespace (step - bandwidth) before letting bars shrink,
63
+ * trading padding for legibility rather than inventing extra height.
64
+ */
65
+ const MIN_GROUPED_BAR_THICKNESS = 8;
66
+
57
67
  // ---------------------------------------------------------------------------
58
68
  // Public API
59
69
  // ---------------------------------------------------------------------------
@@ -70,6 +80,7 @@ export function computeBarMarks(
70
80
  scales: ResolvedScales,
71
81
  _chartArea: Rect,
72
82
  _strategy: LayoutStrategy,
83
+ containerWidth: number,
73
84
  ): RectMark[] {
74
85
  const encoding = spec.encoding as Encoding;
75
86
  const xChannel = encoding.x;
@@ -144,6 +155,7 @@ export function computeBarMarks(
144
155
  bandwidth,
145
156
  baseline,
146
157
  scales,
158
+ containerWidth,
147
159
  );
148
160
  } else {
149
161
  const stackMode =
@@ -267,6 +279,7 @@ function computeGroupedBars(
267
279
  bandwidth: number,
268
280
  baseline: number,
269
281
  scales: ResolvedScales,
282
+ containerWidth: number,
270
283
  ): RectMark[] {
271
284
  const marks: RectMark[] = [];
272
285
  const categoryGroups = groupByField(data, categoryField);
@@ -282,9 +295,36 @@ function computeGroupedBars(
282
295
  const groupCount = groupIndexMap.size;
283
296
  if (groupCount === 0) return marks;
284
297
 
285
- // Subdivide the band height by group count with a small gap
298
+ // Subdivide the band height by group count with a small gap.
286
299
  const gap = Math.min(1, bandwidth * 0.05);
287
- const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
300
+ let groupHeight = bandwidth;
301
+ let subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
302
+
303
+ // At tight bandwidths sub-bars fall below the readable floor and read as
304
+ // stripes. The band scale reserves whitespace between categories (step -
305
+ // bandwidth); reclaim it by growing the group height toward the full step
306
+ // before letting bars shrink. Bars stay centered in the band so the widened
307
+ // group doesn't touch its neighbors. A one-pixel residual keeps adjacent
308
+ // categories visually separable even at full reclaim.
309
+ //
310
+ // Gated to narrow containers (< NARROW_VIEWPORT_MAX): the fix targets phones
311
+ // where this was observed. Gating on the container width (not the derived plot
312
+ // width) keeps desktop embeds pixel-identical even when a wide container's
313
+ // left gutter shrinks the plot below 500px.
314
+ const step = typeof yScale.step === 'function' ? yScale.step() : bandwidth;
315
+ const maxGroupHeight = Math.max(bandwidth, step - MIN_BAR_WIDTH);
316
+ if (
317
+ containerWidth < NARROW_VIEWPORT_MAX &&
318
+ subBandHeight < MIN_GROUPED_BAR_THICKNESS &&
319
+ maxGroupHeight > bandwidth
320
+ ) {
321
+ const needed = MIN_GROUPED_BAR_THICKNESS * groupCount + gap * (groupCount - 1);
322
+ groupHeight = Math.min(Math.max(groupHeight, needed), maxGroupHeight);
323
+ subBandHeight = (groupHeight - gap * (groupCount - 1)) / groupCount;
324
+ }
325
+ subBandHeight = Math.max(subBandHeight, MIN_BAR_WIDTH);
326
+ // Center the (possibly widened) group within the band.
327
+ const groupOffset = (bandwidth - groupHeight) / 2;
288
328
 
289
329
  for (const [category, rows] of categoryGroups) {
290
330
  const bandY = yScale(category);
@@ -299,7 +339,7 @@ function computeGroupedBars(
299
339
  const color = getColor(scales, groupKey);
300
340
  const xPos = value >= 0 ? baseline : xScale(value);
301
341
  const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
302
- const subY = bandY + groupIndex * (subBandHeight + gap);
342
+ const subY = bandY + groupOffset + groupIndex * (subBandHeight + gap);
303
343
 
304
344
  const aria: MarkAria = {
305
345
  label: `${category}, ${groupKey}: ${formatLabelValue(value)}`,
@@ -13,8 +13,15 @@ import { computeBarLabels } from './labels';
13
13
  // Bar chart renderer
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export const barRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
17
- const marks = computeBarMarks(spec, scales, chartArea, strategy);
16
+ export const barRenderer: ChartRenderer = (
17
+ spec,
18
+ scales,
19
+ chartArea,
20
+ strategy,
21
+ theme,
22
+ containerWidth,
23
+ ) => {
24
+ const marks = computeBarMarks(spec, scales, chartArea, strategy, containerWidth);
18
25
 
19
26
  // Compute and attach value labels (respects spec.labels.density)
20
27
  const valueField =
@@ -5,8 +5,10 @@
5
5
  * if the bar is wide enough, or outside (to the right) otherwise.
6
6
  *
7
7
  * Respects the spec's label density setting:
8
- * - 'all': show every label, skip collision detection
9
- * - 'auto': existing behavior (collision detection)
8
+ * - 'all': consider every bar's label (no density pre-filter), but still run
9
+ * collision resolution so colliding losers are hidden rather than drawn on
10
+ * top of each other. Composes with the stacked-segment pre-hide pass below.
11
+ * - 'auto': existing behavior (density pre-filter + collision detection)
10
12
  * - 'endpoints': first and last bars only
11
13
  * - 'none': return empty array
12
14
  */
@@ -106,6 +108,10 @@ export function computeBarLabels(
106
108
  // Track whether each candidate fits within its stacked segment.
107
109
  // Non-stacked bars are always considered fitting (undefined = fits).
108
110
  const fitsInSegment: boolean[] = [];
111
+ // The display anchor x (start/end/middle reference point) for each candidate.
112
+ // Candidates carry the box's LEFT edge for collision math; on emit we map any
113
+ // horizontal nudge back onto this display anchor so textAnchor stays honored.
114
+ const displayAnchorX: number[] = [];
109
115
 
110
116
  const formatter = buildD3Formatter(labelFormat);
111
117
 
@@ -183,17 +189,32 @@ export function computeBarLabels(
183
189
  }
184
190
  }
185
191
 
186
- // anchorY = bar vertical center. With dominant-baseline: central,
187
- // SVG places the text center at this y coordinate.
188
- const anchorY = mark.y + mark.height / 2;
192
+ // Collision math works in top-left-edge space so the AABB
193
+ // (rect = { x: anchorX, y: anchorY, width, height }) matches the label's
194
+ // real footprint. The renderer anchors text by textAnchor (start/end/
195
+ // middle) and centers it vertically (dominant-baseline: central), so we
196
+ // convert the display anchor to the box's top-left corner here and shift
197
+ // back on emit. Feeding the display anchor directly desynced the collision
198
+ // box from the drawn text (by width for end/middle, by textHeight/2
199
+ // vertically), letting adjacent bar labels pass the overlap check while
200
+ // visibly colliding.
201
+ const centerY = mark.y + mark.height / 2;
202
+ const anchorY = centerY - textHeight / 2;
203
+ const leftEdgeX =
204
+ textAnchor === 'end'
205
+ ? anchorX - textWidth
206
+ : textAnchor === 'middle'
207
+ ? anchorX - textWidth / 2
208
+ : anchorX;
189
209
 
190
210
  // Check if label text fits within the stacked segment
191
211
  const fits = !(isStacked && textWidth > mark.width - 2 * LABEL_PADDING);
192
212
 
213
+ displayAnchorX.push(anchorX);
193
214
  fitsInSegment.push(fits);
194
215
  candidates.push({
195
216
  text: valuePart,
196
- anchorX,
217
+ anchorX: leftEdgeX,
197
218
  anchorY,
198
219
  width: textWidth,
199
220
  height: textHeight,
@@ -212,46 +233,76 @@ export function computeBarLabels(
212
233
 
213
234
  if (candidates.length === 0) return [];
214
235
 
215
- // 'all': skip collision detection, mark everything visible
216
- // (but hide labels that don't fit in their stacked segment)
217
- if (density === 'all') {
218
- return candidates.map((c, i) => ({
219
- text: c.text,
220
- x: c.anchorX,
221
- y: c.anchorY,
222
- style: c.style,
223
- visible: fitsInSegment[i] !== false,
224
- }));
225
- }
226
-
227
- // For 'auto' and 'endpoints': pre-mark candidates that don't fit their
228
- // stacked segment as hidden before running collision detection.
236
+ // Every density that reaches here ('all' and 'auto') pre-hides candidates
237
+ // that don't fit their stacked segment, then runs collision resolution.
238
+ // 'all' just skips the density pre-filter above so every mark is a
239
+ // candidate; colliding losers are still hidden by resolveCollisions.
229
240
  const fittingCandidates: LabelCandidate[] = [];
230
241
  const unfittingIndices = new Set<number>();
231
242
  for (let i = 0; i < candidates.length; i++) {
232
243
  if (fitsInSegment[i] === false) {
233
244
  unfittingIndices.add(i);
234
245
  } else {
235
- fittingCandidates.push(candidates[i]);
246
+ // Carry the original candidate index so we can zip the resolved output
247
+ // back to it regardless of any reordering resolveCollisions performs.
248
+ fittingCandidates.push({ ...candidates[i], index: i });
236
249
  }
237
250
  }
238
251
 
239
- const resolved = resolveCollisions(fittingCandidates);
252
+ // resolveCollisions may reorder its output (it sorts by priority), so key the
253
+ // result by the source index we carried rather than by position.
254
+ const resolvedByOrig = new Map<number, ResolvedLabel>();
255
+ for (const r of resolveCollisions(fittingCandidates)) {
256
+ if (r.index !== undefined) resolvedByOrig.set(r.index, r);
257
+ }
258
+
259
+ // Shift resolved y back from top-edge space to the vertical center that the
260
+ // central-baseline renderer expects (see anchorY derivation above).
261
+ const halfHeight = FONT_SIZE * 1.2 * 0.5;
240
262
 
241
263
  // Re-insert hidden labels for candidates that didn't fit, preserving order
242
264
  const results: ResolvedLabel[] = [];
243
- let resolvedIdx = 0;
244
265
  for (let i = 0; i < candidates.length; i++) {
245
266
  if (unfittingIndices.has(i)) {
246
267
  results.push({
247
268
  text: candidates[i].text,
248
- x: candidates[i].anchorX,
249
- y: candidates[i].anchorY,
269
+ x: displayAnchorX[i],
270
+ y: candidates[i].anchorY + halfHeight,
250
271
  style: candidates[i].style,
251
272
  visible: false,
252
273
  });
253
274
  } else {
254
- results.push(resolved[resolvedIdx++]);
275
+ const r = resolvedByOrig.get(i);
276
+ if (!r) continue;
277
+ // Collision math ran in left-edge/top space; renderers draw at the display
278
+ // anchor with central baseline. The inverse mapping shifts every point by
279
+ // dx (left-edge -> display anchor) on x and +halfHeight on y. Apply it to
280
+ // the label AND both connector endpoints, or the leader line for an
281
+ // end/middle-anchored label points to the box's top-left corner (up to
282
+ // textWidth + halfHeight away from the text it's calling out).
283
+ const dx = displayAnchorX[i] - candidates[i].anchorX;
284
+ // Drop the internal zip index so it doesn't leak into the label output.
285
+ const { index: _index, ...rest } = r;
286
+ results.push({
287
+ ...rest,
288
+ x: r.x + dx,
289
+ y: r.y + halfHeight,
290
+ connector: r.connector
291
+ ? {
292
+ ...r.connector,
293
+ from: {
294
+ ...r.connector.from,
295
+ x: r.connector.from.x + dx,
296
+ y: r.connector.from.y + halfHeight,
297
+ },
298
+ to: {
299
+ ...r.connector.to,
300
+ x: r.connector.to.x + dx,
301
+ y: r.connector.to.y + halfHeight,
302
+ },
303
+ }
304
+ : undefined,
305
+ });
255
306
  }
256
307
  }
257
308
 
@@ -5,8 +5,10 @@
5
5
  * or below (for negative values).
6
6
  *
7
7
  * Respects the spec's label density setting:
8
- * - 'all': show every label, skip collision detection
9
- * - 'auto': existing behavior (collision detection)
8
+ * - 'all': consider every column's label (no density pre-filter), but still
9
+ * run collision resolution so colliding losers are hidden rather than drawn
10
+ * on top of each other
11
+ * - 'auto': existing behavior (density pre-filter + collision detection)
10
12
  * - 'endpoints': first and last columns only
11
13
  * - 'none': return empty array
12
14
  */
@@ -22,6 +24,7 @@ import {
22
24
  estimateTextWidth,
23
25
  getRepresentativeColor,
24
26
  resolveCollisions,
27
+ textAscent,
25
28
  } from '@opendata-ai/openchart-core';
26
29
  import { filterByDensity } from '../_shared/density-filter';
27
30
  import { formatLabelValue } from '../_shared/format-label-value';
@@ -97,7 +100,8 @@ export function computeColumnLabels(
97
100
 
98
101
  // anchorY is the TOP of the label bounding box so the collision system's
99
102
  // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
100
- // correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
103
+ // correct. Collision math stays in top-coordinate space; the ascent shift
104
+ // to the alphabetic baseline is applied only at emission (see below).
101
105
  // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
102
106
  // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
103
107
  const anchorX = mark.x + mark.width / 2;
@@ -119,23 +123,32 @@ export function computeColumnLabels(
119
123
  fill: labelColor ?? getRepresentativeColor(mark.fill),
120
124
  lineHeight: 1.2,
121
125
  textAnchor: 'middle',
122
- dominantBaseline: 'hanging',
123
126
  },
124
127
  });
125
128
  }
126
129
 
127
130
  if (candidates.length === 0) return [];
128
131
 
129
- // 'all': skip collision detection, mark everything visible
130
- if (density === 'all') {
131
- return candidates.map((c) => ({
132
- text: c.text,
133
- x: c.anchorX,
134
- y: c.anchorY,
135
- style: c.style,
136
- visible: true,
137
- }));
138
- }
139
-
140
- return resolveCollisions(candidates);
132
+ // Collision resolution keeps y in top-edge coordinates; renderers position
133
+ // text on the alphabetic baseline, so shift every emitted y down by the
134
+ // ascent instead of relying on dominant-baseline:hanging (WebKit computes
135
+ // hanging from different font metrics than Blink, clipping labels on iOS).
136
+ const ascent = textAscent(FONT_SIZE);
137
+
138
+ // Every density that reaches here ('all' and 'auto') runs collision
139
+ // resolution. 'all' just skips the density pre-filter above so every mark is
140
+ // a candidate; colliding losers are still hidden by resolveCollisions rather
141
+ // than stacked on top of each other.
142
+ return resolveCollisions(candidates).map((label) => ({
143
+ ...label,
144
+ y: label.y + ascent,
145
+ // The connector originates at the label; shift its start to the baseline
146
+ // too so the leader stays glued to the shifted text.
147
+ connector: label.connector
148
+ ? {
149
+ ...label.connector,
150
+ from: { ...label.connector.from, y: label.connector.from.y + ascent },
151
+ }
152
+ : undefined,
153
+ }));
141
154
  }
@@ -5,14 +5,17 @@
5
5
  */
6
6
 
7
7
  import type {
8
+ AreaMark,
8
9
  Encoding,
10
+ LineMark,
9
11
  Mark,
10
12
  MarkDef,
11
13
  PointMark,
12
- Rect,
13
14
  RectMark,
14
15
  ResolvedAnimation,
15
16
  } from '@opendata-ai/openchart-core';
17
+ import type { PlacementObstacle } from '../annotations/placement';
18
+ import { sampleAreaObstacles, samplePolylineObstacles } from '../annotations/placement';
16
19
  import type { ResolvedScales } from '../layout/scales';
17
20
 
18
21
  // ---------------------------------------------------------------------------
@@ -28,18 +31,17 @@ import type { ResolvedScales } from '../layout/scales';
28
31
  * For other charts (column, scatter): returns individual mark bounds so
29
32
  * annotations avoid overlapping any visible data mark.
30
33
  */
31
- export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
34
+ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
32
35
  // Band-scale y-axis: group marks by row for efficient obstacle computation
33
36
  if (scales.y?.type === 'band') {
34
37
  return computeBandRowObstacles(marks, scales);
35
38
  }
36
39
 
37
- // All other charts: use individual rect/point mark bounds as obstacles
38
- const obstacles: Rect[] = [];
40
+ const obstacles: PlacementObstacle[] = [];
39
41
  for (const mark of marks) {
40
42
  if (mark.type === 'rect') {
41
43
  const rm = mark as RectMark;
42
- obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height });
44
+ obstacles.push({ x: rm.x, y: rm.y, width: rm.width, height: rm.height, kind: 'mark' });
43
45
  } else if (mark.type === 'point') {
44
46
  const pm = mark as PointMark;
45
47
  obstacles.push({
@@ -47,14 +49,25 @@ export function computeMarkObstacles(marks: Mark[], scales: ResolvedScales): Rec
47
49
  y: pm.cy - pm.r,
48
50
  width: pm.r * 2,
49
51
  height: pm.r * 2,
52
+ kind: 'mark',
50
53
  });
54
+ } else if (mark.type === 'line') {
55
+ const lm = mark as LineMark;
56
+ if (lm.points.length > 0) {
57
+ obstacles.push(...samplePolylineObstacles(lm.points, 'line'));
58
+ }
59
+ } else if (mark.type === 'area') {
60
+ const am = mark as AreaMark;
61
+ if (am.topPoints.length > 0) {
62
+ obstacles.push(...sampleAreaObstacles(am.topPoints, am.bottomPoints));
63
+ }
51
64
  }
52
65
  }
53
66
  return obstacles;
54
67
  }
55
68
 
56
69
  /** Group band-scale marks by row, returning one obstacle per band. */
57
- function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
70
+ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): PlacementObstacle[] {
58
71
  const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
59
72
 
60
73
  for (const mark of marks) {
@@ -92,13 +105,14 @@ function computeBandRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[]
92
105
  const bandwidth = bandScale.bandwidth?.() ?? 0;
93
106
  if (bandwidth === 0) return [];
94
107
 
95
- const obstacles: Rect[] = [];
108
+ const obstacles: PlacementObstacle[] = [];
96
109
  for (const { minX, maxX, bandY } of rows.values()) {
97
110
  obstacles.push({
98
111
  x: minX,
99
112
  y: bandY - bandwidth / 2,
100
113
  width: maxX - minX,
101
114
  height: bandwidth,
115
+ kind: 'mark',
102
116
  });
103
117
  }
104
118
 
@@ -19,8 +19,9 @@ import type { ResolvedScales } from '../layout/scales';
19
19
  * A chart renderer function.
20
20
  *
21
21
  * Takes a normalized spec, resolved scales, chart area, layout strategy,
22
- * and the resolved theme for theme-aware styling (e.g. label colors).
23
- * Returns an array of marks to render.
22
+ * the resolved theme for theme-aware styling (e.g. label colors), and the
23
+ * full container width (used by geometry gates that must key off the
24
+ * container, not the derived plot width). Returns an array of marks to render.
24
25
  */
25
26
  export type ChartRenderer = (
26
27
  spec: NormalizedChartSpec,
@@ -28,6 +29,7 @@ export type ChartRenderer = (
28
29
  chartArea: Rect,
29
30
  strategy: LayoutStrategy,
30
31
  theme: ResolvedTheme,
32
+ containerWidth: number,
31
33
  ) => Mark[];
32
34
 
33
35
  // ---------------------------------------------------------------------------
@@ -9,8 +9,10 @@ import type {
9
9
  } from '@opendata-ai/openchart-core';
10
10
  import {
11
11
  AXIS_TITLE_TRAILING_PAD,
12
+ abbreviateNumber,
12
13
  BREAKPOINT_COMPACT_MAX,
13
14
  estimateTextWidth,
15
+ formatNumber,
14
16
  getAxisTitleOffset,
15
17
  resolveTheme,
16
18
  TICK_LABEL_OFFSET,
@@ -19,6 +21,8 @@ import { format as d3Format } from 'd3-format';
19
21
  import { scaleLinear } from 'd3-scale';
20
22
  import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape';
21
23
  import { flattenLayers } from '../compiler/index';
24
+ import type { MeasureFn } from '../layout/plan';
25
+ import { createMeasureFn } from '../layout/plan';
22
26
 
23
27
  type ChartCompiler = (spec: unknown, options: CompileOptions) => ChartLayout;
24
28
 
@@ -100,18 +104,21 @@ function estimateYAxisLabelWidth(
100
104
  data: DataRow[],
101
105
  encoding: Encoding | undefined,
102
106
  baseFontSize: number,
107
+ measure?: MeasureFn,
103
108
  ): number {
104
109
  if (!encoding?.y) return 40;
105
110
  const yEnc = encoding.y;
106
111
  const yField = yEnc.field;
107
112
  if (!yField) return 40;
108
113
 
114
+ const m = measure ?? estimateTextWidth;
115
+
109
116
  const yType = yEnc.type;
110
117
  if (yType === 'nominal' || yType === 'ordinal') {
111
118
  let maxWidth = 0;
112
119
  for (const row of data) {
113
120
  const label = String(row[yField] ?? '');
114
- const w = estimateTextWidth(label, baseFontSize, 400);
121
+ const w = m(label, baseFontSize, 400);
115
122
  if (w > maxWidth) maxWidth = w;
116
123
  }
117
124
  return maxWidth > 0 ? maxWidth + 10 : 40;
@@ -134,16 +141,13 @@ function estimateYAxisLabelWidth(
134
141
  sampleLabel = String(maxAbsVal);
135
142
  }
136
143
  } else {
137
- if (maxAbsVal >= 1_000_000_000) sampleLabel = '1.5B';
138
- else if (maxAbsVal >= 1_000_000) sampleLabel = '1.5M';
139
- else if (maxAbsVal >= 1_000) sampleLabel = '1.5K';
140
- else if (maxAbsVal >= 100) sampleLabel = '100';
141
- else if (maxAbsVal >= 10) sampleLabel = '10';
142
- else sampleLabel = '0.0';
144
+ // Use the same formatting as tick labels instead of hardcoded magnitude guesses
145
+ sampleLabel =
146
+ Math.abs(maxAbsVal) >= 1000 ? abbreviateNumber(maxAbsVal) : formatNumber(maxAbsVal);
143
147
  }
144
148
  const hasNeg = data.some((r) => Number(r[yField]) < 0);
145
149
  const labelEst = (hasNeg ? '-' : '') + sampleLabel;
146
- return estimateTextWidth(labelEst, baseFontSize, 400) + 10;
150
+ return m(labelEst, baseFontSize, 400) + 10;
147
151
  }
148
152
 
149
153
  function compileLayerIndependent(
@@ -173,7 +177,13 @@ function compileLayerIndependent(
173
177
 
174
178
  const theme = resolveTheme(layerSpec.theme ?? leaf1.theme);
175
179
  const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11;
176
- const rightAxisWidth = estimateYAxisLabelWidth(leaf1.data, leaf1.encoding, axisFontSize);
180
+ const measureFn = createMeasureFn(options.measureText);
181
+ const rightAxisWidth = estimateYAxisLabelWidth(
182
+ leaf1.data,
183
+ leaf1.encoding,
184
+ axisFontSize,
185
+ measureFn,
186
+ );
177
187
  const yAxisConfig = leaf1.encoding?.y?.axis || undefined;
178
188
  const hasRightAxisTitle = !!yAxisConfig?.title;
179
189
  const tickExtent = TICK_LABEL_OFFSET + rightAxisWidth;
@@ -227,6 +237,18 @@ function compileLayerIndependent(
227
237
  x: layout0.area.x + layout0.area.width,
228
238
  y: layout0.area.y + layout0.area.height,
229
239
  },
240
+ ...(layout1.axes.y.label
241
+ ? {
242
+ titlePosition: {
243
+ x:
244
+ layout0.area.x +
245
+ layout0.area.width +
246
+ getAxisTitleOffset(layout0.dimensions.width),
247
+ y: layout0.area.y + layout0.area.height / 2,
248
+ angle: 90,
249
+ },
250
+ }
251
+ : {}),
230
252
  }
231
253
  : undefined;
232
254