@opendata-ai/openchart-engine 2.3.5 → 2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.3.5",
3
+ "version": "2.4.0",
4
4
  "description": "Headless compiler for openchart: spec validation, data compilation, scales, and layout",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.3.5",
48
+ "@opendata-ai/openchart-core": "2.4.0",
49
49
  "d3-array": "^3.2.0",
50
50
  "d3-format": "^3.1.2",
51
51
  "d3-interpolate": "^3.0.0",
@@ -2,7 +2,7 @@ import type { LayoutStrategy } from '@opendata-ai/openchart-core';
2
2
  import { resolveTheme } from '@opendata-ai/openchart-core';
3
3
  import { describe, expect, it } from 'vitest';
4
4
  import type { NormalizedChartSpec } from '../compiler/types';
5
- import { computeAxes } from '../layout/axes';
5
+ import { computeAxes, effectiveDensity } from '../layout/axes';
6
6
  import { computeScales } from '../layout/scales';
7
7
 
8
8
  const lineSpec: NormalizedChartSpec = {
@@ -111,4 +111,188 @@ describe('computeAxes', () => {
111
111
  expect(axes.y!.start.x).toBe(chartArea.x);
112
112
  expect(axes.y!.end.x).toBe(chartArea.x);
113
113
  });
114
+
115
+ // -------------------------------------------------------------------------
116
+ // Height-aware y-axis tick reduction
117
+ // -------------------------------------------------------------------------
118
+
119
+ it('reduces y-axis ticks for very short chart areas (< 120px)', () => {
120
+ const shortArea = { x: 50, y: 50, width: 500, height: 80 };
121
+ const scales = computeScales(lineSpec, shortArea, lineSpec.data);
122
+ const axesShort = computeAxes(scales, shortArea, fullStrategy, theme);
123
+
124
+ // Even though the strategy says 'full', height < 120 forces minimal (3 ticks)
125
+ expect(axesShort.y!.ticks.length).toBeLessThanOrEqual(3);
126
+ });
127
+
128
+ it('reduces y-axis ticks for medium-short chart areas (120-200px)', () => {
129
+ const mediumArea = { x: 50, y: 50, width: 500, height: 160 };
130
+ const tallArea = { x: 50, y: 50, width: 500, height: 400 };
131
+
132
+ const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
133
+ const scalesTall = computeScales(lineSpec, tallArea, lineSpec.data);
134
+
135
+ const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
136
+ const axesTall = computeAxes(scalesTall, tallArea, fullStrategy, theme);
137
+
138
+ // Medium height should have fewer ticks than a tall chart with same 'full' density
139
+ expect(axesMedium.y!.ticks.length).toBeLessThanOrEqual(axesTall.y!.ticks.length);
140
+ });
141
+
142
+ it('does not increase y-axis ticks beyond base density for short charts', () => {
143
+ const shortArea = { x: 50, y: 50, width: 500, height: 80 };
144
+ const scales = computeScales(lineSpec, shortArea, lineSpec.data);
145
+
146
+ // Strategy already says minimal - short height shouldn't change anything
147
+ const axes = computeAxes(scales, shortArea, minimalStrategy, theme);
148
+ expect(axes.y!.ticks.length).toBeLessThanOrEqual(3);
149
+ });
150
+
151
+ // -------------------------------------------------------------------------
152
+ // Width-aware x-axis tick reduction
153
+ // -------------------------------------------------------------------------
154
+
155
+ it('reduces x-axis ticks for very narrow chart areas (< 150px)', () => {
156
+ const narrowArea = { x: 50, y: 50, width: 100, height: 300 };
157
+ const scales = computeScales(lineSpec, narrowArea, lineSpec.data);
158
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
159
+
160
+ // Width < 150 forces minimal density for x-axis
161
+ expect(axes.x!.ticks.length).toBeLessThanOrEqual(3);
162
+ });
163
+
164
+ it('reduces x-axis ticks for medium-narrow chart areas (150-300px)', () => {
165
+ const mediumArea = { x: 50, y: 50, width: 250, height: 300 };
166
+ const wideArea = { x: 50, y: 50, width: 600, height: 300 };
167
+
168
+ const scalesMedium = computeScales(lineSpec, mediumArea, lineSpec.data);
169
+ const scalesWide = computeScales(lineSpec, wideArea, lineSpec.data);
170
+
171
+ const axesMedium = computeAxes(scalesMedium, mediumArea, fullStrategy, theme);
172
+ const axesWide = computeAxes(scalesWide, wideArea, fullStrategy, theme);
173
+
174
+ expect(axesMedium.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
175
+ });
176
+
177
+ // -------------------------------------------------------------------------
178
+ // Both axes constrained simultaneously (thumbnail scenario)
179
+ // -------------------------------------------------------------------------
180
+
181
+ it('reduces ticks on both axes for thumbnail-sized charts', () => {
182
+ const thumbnailArea = { x: 10, y: 10, width: 120, height: 80 };
183
+ const fullArea = { x: 50, y: 50, width: 600, height: 400 };
184
+
185
+ const scalesThumb = computeScales(lineSpec, thumbnailArea, lineSpec.data);
186
+ const scalesFull = computeScales(lineSpec, fullArea, lineSpec.data);
187
+
188
+ const axesThumb = computeAxes(scalesThumb, thumbnailArea, fullStrategy, theme);
189
+ const axesFull = computeAxes(scalesFull, fullArea, fullStrategy, theme);
190
+
191
+ // Both axes should have minimal ticks in a thumbnail
192
+ expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(3);
193
+ expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(3);
194
+
195
+ // And fewer than full-size
196
+ expect(axesThumb.x!.ticks.length).toBeLessThanOrEqual(axesFull.x!.ticks.length);
197
+ expect(axesThumb.y!.ticks.length).toBeLessThanOrEqual(axesFull.y!.ticks.length);
198
+ });
199
+
200
+ // -------------------------------------------------------------------------
201
+ // tickAngle propagation
202
+ // -------------------------------------------------------------------------
203
+
204
+ it('propagates tickAngle from encoding to x-axis layout', () => {
205
+ const specWithAngle: NormalizedChartSpec = {
206
+ ...lineSpec,
207
+ type: 'column',
208
+ data: [
209
+ { cat: 'California', val: 10 },
210
+ { cat: 'New York', val: 20 },
211
+ ],
212
+ encoding: {
213
+ x: { field: 'cat', type: 'nominal', axis: { tickAngle: -90 } },
214
+ y: { field: 'val', type: 'quantitative' },
215
+ },
216
+ };
217
+ const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
218
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
219
+
220
+ expect(axes.x!.tickAngle).toBe(-90);
221
+ });
222
+
223
+ it('leaves tickAngle undefined when not specified', () => {
224
+ const scales = computeScales(lineSpec, chartArea, lineSpec.data);
225
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
226
+
227
+ expect(axes.x!.tickAngle).toBeUndefined();
228
+ expect(axes.y!.tickAngle).toBeUndefined();
229
+ });
230
+
231
+ it('propagates tickAngle to y-axis layout', () => {
232
+ const specWithAngle: NormalizedChartSpec = {
233
+ ...lineSpec,
234
+ encoding: {
235
+ x: { field: 'date', type: 'temporal' },
236
+ y: { field: 'value', type: 'quantitative', axis: { tickAngle: -45 } },
237
+ },
238
+ };
239
+ const scales = computeScales(specWithAngle, chartArea, specWithAngle.data);
240
+ const axes = computeAxes(scales, chartArea, fullStrategy, theme);
241
+
242
+ expect(axes.y!.tickAngle).toBe(-45);
243
+ });
244
+ });
245
+
246
+ // ---------------------------------------------------------------------------
247
+ // effectiveDensity unit tests
248
+ // ---------------------------------------------------------------------------
249
+
250
+ describe('effectiveDensity', () => {
251
+ const MINIMAL_THRESHOLD = 120;
252
+ const REDUCED_THRESHOLD = 200;
253
+
254
+ it('returns base density when axis length exceeds all thresholds', () => {
255
+ expect(effectiveDensity('full', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
256
+ expect(effectiveDensity('reduced', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
257
+ expect(effectiveDensity('minimal', 500, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
258
+ });
259
+
260
+ it('forces minimal density below the minimal threshold', () => {
261
+ expect(effectiveDensity('full', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
262
+ expect(effectiveDensity('reduced', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
263
+ expect(effectiveDensity('minimal', 80, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
264
+ });
265
+
266
+ it('caps at reduced density between thresholds', () => {
267
+ // 'full' base should step down to 'reduced'
268
+ expect(effectiveDensity('full', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
269
+ });
270
+
271
+ it('does not increase density beyond base when between thresholds', () => {
272
+ // 'minimal' base should stay 'minimal' even between thresholds
273
+ expect(effectiveDensity('minimal', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
274
+ // 'reduced' base should stay 'reduced'
275
+ expect(effectiveDensity('reduced', 160, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
276
+ });
277
+
278
+ it('handles exact threshold boundaries', () => {
279
+ // At exactly the minimal threshold, we're NOT below it
280
+ expect(effectiveDensity('full', 120, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('reduced');
281
+ // At exactly the reduced threshold, we're NOT below it
282
+ expect(effectiveDensity('full', 200, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('full');
283
+ });
284
+
285
+ it('handles zero and negative lengths', () => {
286
+ expect(effectiveDensity('full', 0, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
287
+ expect(effectiveDensity('full', -10, MINIMAL_THRESHOLD, REDUCED_THRESHOLD)).toBe('minimal');
288
+ });
289
+
290
+ it('works with custom thresholds (x-axis uses different values)', () => {
291
+ const X_MINIMAL = 150;
292
+ const X_REDUCED = 300;
293
+
294
+ expect(effectiveDensity('full', 100, X_MINIMAL, X_REDUCED)).toBe('minimal');
295
+ expect(effectiveDensity('full', 200, X_MINIMAL, X_REDUCED)).toBe('reduced');
296
+ expect(effectiveDensity('full', 400, X_MINIMAL, X_REDUCED)).toBe('full');
297
+ });
114
298
  });
@@ -457,4 +457,56 @@ describe('compileGraph', () => {
457
457
  ),
458
458
  ).toThrow('compileGraph received a scatter spec');
459
459
  });
460
+
461
+ it('propagates tickAngle through the full compilation pipeline', () => {
462
+ const columnSpec = {
463
+ type: 'column' as const,
464
+ data: [
465
+ { state: 'California', pop: 39000000 },
466
+ { state: 'Texas', pop: 29000000 },
467
+ { state: 'Florida', pop: 22000000 },
468
+ { state: 'New York', pop: 20000000 },
469
+ { state: 'Pennsylvania', pop: 13000000 },
470
+ ],
471
+ encoding: {
472
+ x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
473
+ y: { field: 'pop', type: 'quantitative' as const },
474
+ },
475
+ };
476
+
477
+ const layout = compileChart(columnSpec, { width: 400, height: 300 });
478
+
479
+ // tickAngle should be propagated to the x-axis layout
480
+ expect(layout.axes.x!.tickAngle).toBe(-90);
481
+ // y-axis should not have a tickAngle
482
+ expect(layout.axes.y!.tickAngle).toBeUndefined();
483
+ });
484
+
485
+ it('reserves extra bottom margin for rotated x-axis labels', () => {
486
+ const baseColumnSpec = {
487
+ type: 'column' as const,
488
+ data: [
489
+ { state: 'California', pop: 39000000 },
490
+ { state: 'Texas', pop: 29000000 },
491
+ { state: 'Massachusetts', pop: 7000000 },
492
+ ],
493
+ encoding: {
494
+ x: { field: 'state', type: 'nominal' as const },
495
+ y: { field: 'pop', type: 'quantitative' as const },
496
+ },
497
+ };
498
+ const rotatedColumnSpec = {
499
+ ...baseColumnSpec,
500
+ encoding: {
501
+ x: { field: 'state', type: 'nominal' as const, axis: { tickAngle: -90 } },
502
+ y: { field: 'pop', type: 'quantitative' as const },
503
+ },
504
+ };
505
+
506
+ const layoutNormal = compileChart(baseColumnSpec, { width: 400, height: 300 });
507
+ const layoutRotated = compileChart(rotatedColumnSpec, { width: 400, height: 300 });
508
+
509
+ // Rotated labels should shrink the chart area height
510
+ expect(layoutRotated.area.height).toBeLessThan(layoutNormal.area.height);
511
+ });
460
512
  });
@@ -148,4 +148,72 @@ describe('computeDimensions', () => {
148
148
  expect(dims.chartArea.width).toBeGreaterThanOrEqual(0);
149
149
  expect(dims.chartArea.height).toBeGreaterThanOrEqual(0);
150
150
  });
151
+
152
+ it('reserves extra bottom space for rotated x-axis labels', () => {
153
+ const rotatedSpec: NormalizedChartSpec = {
154
+ ...baseSpec,
155
+ type: 'column',
156
+ data: [
157
+ { category: 'California', value: 10 },
158
+ { category: 'New York', value: 20 },
159
+ { category: 'Massachusetts', value: 15 },
160
+ ],
161
+ encoding: {
162
+ x: { field: 'category', type: 'nominal', axis: { tickAngle: -90 } },
163
+ y: { field: 'value', type: 'quantitative' },
164
+ },
165
+ };
166
+ const normalSpec: NormalizedChartSpec = {
167
+ ...baseSpec,
168
+ type: 'column',
169
+ data: rotatedSpec.data,
170
+ encoding: {
171
+ x: { field: 'category', type: 'nominal' },
172
+ y: { field: 'value', type: 'quantitative' },
173
+ },
174
+ };
175
+
176
+ const dimsRotated = computeDimensions(
177
+ rotatedSpec,
178
+ { width: 600, height: 400 },
179
+ emptyLegend,
180
+ lightTheme,
181
+ );
182
+ const dimsNormal = computeDimensions(
183
+ normalSpec,
184
+ { width: 600, height: 400 },
185
+ emptyLegend,
186
+ lightTheme,
187
+ );
188
+
189
+ // Rotated labels should reserve more bottom space, shrinking the chart area
190
+ expect(dimsRotated.chartArea.height).toBeLessThan(dimsNormal.chartArea.height);
191
+ expect(dimsRotated.margins.bottom).toBeGreaterThan(dimsNormal.margins.bottom);
192
+ });
193
+
194
+ it('does not change bottom space for small tick angles', () => {
195
+ const smallAngleSpec: NormalizedChartSpec = {
196
+ ...baseSpec,
197
+ encoding: {
198
+ x: { field: 'date', type: 'temporal', axis: { tickAngle: 5 } },
199
+ y: { field: 'value', type: 'quantitative' },
200
+ },
201
+ };
202
+
203
+ const dimsSmall = computeDimensions(
204
+ smallAngleSpec,
205
+ { width: 600, height: 400 },
206
+ emptyLegend,
207
+ lightTheme,
208
+ );
209
+ const dimsNone = computeDimensions(
210
+ baseSpec,
211
+ { width: 600, height: 400 },
212
+ emptyLegend,
213
+ lightTheme,
214
+ );
215
+
216
+ // Small angles (< 10 degrees) should not trigger rotated label logic
217
+ expect(dimsSmall.margins.bottom).toBe(dimsNone.margins.bottom);
218
+ });
151
219
  });
@@ -35,6 +35,60 @@ const TICK_COUNTS: Record<AxisLabelDensity, number> = {
35
35
  minimal: 3,
36
36
  };
37
37
 
38
+ /**
39
+ * Height thresholds for reducing y-axis tick density.
40
+ * Below these pixel heights, we step down the density regardless of the
41
+ * width-based strategy. This prevents overlapping y-axis labels in short
42
+ * containers like thumbnail previews.
43
+ */
44
+ const HEIGHT_MINIMAL_THRESHOLD = 120;
45
+ const HEIGHT_REDUCED_THRESHOLD = 200;
46
+
47
+ /**
48
+ * Width thresholds for reducing x-axis tick density.
49
+ * Mirrors the height logic for the x-axis: narrow containers get fewer ticks.
50
+ */
51
+ const WIDTH_MINIMAL_THRESHOLD = 150;
52
+ const WIDTH_REDUCED_THRESHOLD = 300;
53
+
54
+ /** Ordered densities from most to fewest ticks. */
55
+ const DENSITY_ORDER: AxisLabelDensity[] = ['full', 'reduced', 'minimal'];
56
+
57
+ /**
58
+ * Compute effective axis tick density by considering available space.
59
+ *
60
+ * The width-based breakpoint system sets a base density, but it doesn't know
61
+ * about the actual chart area dimensions (which shrink after chrome/legend
62
+ * allocation). This function steps density down further when the axis
63
+ * dimension is too small for the requested tick count.
64
+ *
65
+ * @param baseDensity - The density from the responsive layout strategy.
66
+ * @param axisLength - Available pixels along this axis (height for y, width for x).
67
+ * @param minimalThreshold - Below this pixel size, force minimal density.
68
+ * @param reducedThreshold - Below this pixel size, cap at reduced density.
69
+ * @returns The effective density, never looser than the base.
70
+ */
71
+ export function effectiveDensity(
72
+ baseDensity: AxisLabelDensity,
73
+ axisLength: number,
74
+ minimalThreshold: number,
75
+ reducedThreshold: number,
76
+ ): AxisLabelDensity {
77
+ let density = baseDensity;
78
+
79
+ if (axisLength < minimalThreshold) {
80
+ density = 'minimal';
81
+ } else if (axisLength < reducedThreshold) {
82
+ // Don't increase density beyond what the base strategy allows.
83
+ // If base is already 'minimal', keep it.
84
+ const baseIdx = DENSITY_ORDER.indexOf(baseDensity);
85
+ const reducedIdx = DENSITY_ORDER.indexOf('reduced');
86
+ density = DENSITY_ORDER[Math.max(baseIdx, reducedIdx)];
87
+ }
88
+
89
+ return density;
90
+ }
91
+
38
92
  // ---------------------------------------------------------------------------
39
93
  // Tick generation
40
94
  // ---------------------------------------------------------------------------
@@ -127,7 +181,22 @@ export function computeAxes(
127
181
  theme: ResolvedTheme,
128
182
  ): AxesResult {
129
183
  const result: AxesResult = {};
130
- const density = strategy.axisLabelDensity;
184
+ const baseDensity = strategy.axisLabelDensity;
185
+
186
+ // Compute per-axis density based on available space.
187
+ // Y-axis density adapts to chart height; X-axis density adapts to chart width.
188
+ const yDensity = effectiveDensity(
189
+ baseDensity,
190
+ chartArea.height,
191
+ HEIGHT_MINIMAL_THRESHOLD,
192
+ HEIGHT_REDUCED_THRESHOLD,
193
+ );
194
+ const xDensity = effectiveDensity(
195
+ baseDensity,
196
+ chartArea.width,
197
+ WIDTH_MINIMAL_THRESHOLD,
198
+ WIDTH_REDUCED_THRESHOLD,
199
+ );
131
200
 
132
201
  const tickLabelStyle: TextStyle = {
133
202
  fontFamily: theme.fonts.family,
@@ -149,8 +218,8 @@ export function computeAxes(
149
218
  if (scales.x) {
150
219
  const ticks =
151
220
  scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
152
- ? categoricalTicks(scales.x, density)
153
- : continuousTicks(scales.x, density);
221
+ ? categoricalTicks(scales.x, xDensity)
222
+ : continuousTicks(scales.x, xDensity);
154
223
 
155
224
  const gridlines: Gridline[] = ticks.map((t) => ({
156
225
  position: t.position,
@@ -163,6 +232,7 @@ export function computeAxes(
163
232
  label: scales.x.channel.axis?.label,
164
233
  labelStyle: axisLabelStyle,
165
234
  tickLabelStyle,
235
+ tickAngle: scales.x.channel.axis?.tickAngle,
166
236
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
167
237
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
168
238
  };
@@ -171,8 +241,8 @@ export function computeAxes(
171
241
  if (scales.y) {
172
242
  const ticks =
173
243
  scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
174
- ? categoricalTicks(scales.y, density)
175
- : continuousTicks(scales.y, density);
244
+ ? categoricalTicks(scales.y, yDensity)
245
+ : continuousTicks(scales.y, yDensity);
176
246
 
177
247
  const gridlines: Gridline[] = ticks.map((t) => ({
178
248
  position: t.position,
@@ -186,6 +256,7 @@ export function computeAxes(
186
256
  label: scales.y.channel.axis?.label,
187
257
  labelStyle: axisLabelStyle,
188
258
  tickLabelStyle,
259
+ tickAngle: scales.y.channel.axis?.tickAngle,
189
260
  start: { x: chartArea.x, y: chartArea.y },
190
261
  end: { x: chartArea.x, y: chartArea.y + chartArea.height },
191
262
  };
@@ -90,8 +90,34 @@ export function computeDimensions(
90
90
  // Estimate x-axis height below chart area: tick labels sit 14px below,
91
91
  // axis title sits 35px below. These extend past the chart area bottom
92
92
  // and source/footer chrome must be positioned below them.
93
- const hasXAxisLabel = !!(encoding.x?.axis as Record<string, unknown> | undefined)?.label;
94
- const xAxisHeight = isRadial ? 0 : hasXAxisLabel ? 48 : 26;
93
+ const xAxis = encoding.x?.axis as (Record<string, unknown> & { tickAngle?: number }) | undefined;
94
+ const hasXAxisLabel = !!xAxis?.label;
95
+ const xTickAngle = xAxis?.tickAngle;
96
+
97
+ let xAxisHeight: number;
98
+ if (isRadial) {
99
+ xAxisHeight = 0;
100
+ } else if (xTickAngle && Math.abs(xTickAngle) > 10) {
101
+ // Rotated labels: estimate height from the longest label text.
102
+ // At -90 degrees, the label height = text width. At -45, it's width * sin(45).
103
+ const angleRad = Math.abs(xTickAngle) * (Math.PI / 180);
104
+ const xField = encoding.x?.field;
105
+ let maxLabelWidth = 40; // fallback
106
+ if (xField) {
107
+ for (const row of spec.data) {
108
+ const label = String(row[xField] ?? '');
109
+ const w = estimateTextWidth(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
110
+ if (w > maxLabelWidth) maxLabelWidth = w;
111
+ }
112
+ }
113
+ // Rotated label height: width * sin(angle), plus a small gap
114
+ const rotatedHeight = maxLabelWidth * Math.sin(angleRad) + 6;
115
+ // Cap at a reasonable max to avoid absurd margins
116
+ const labelHeight = Math.min(rotatedHeight, 120);
117
+ xAxisHeight = hasXAxisLabel ? labelHeight + 20 : labelHeight;
118
+ } else {
119
+ xAxisHeight = hasXAxisLabel ? 48 : 26;
120
+ }
95
121
 
96
122
  // Build margins: padding + chrome + axis space
97
123
  const margins: Margins = {