@opendata-ai/openchart-engine 6.23.1 → 6.24.1

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": "6.23.1",
3
+ "version": "6.24.1",
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",
@@ -48,7 +48,7 @@
48
48
  "typecheck": "tsc --noEmit"
49
49
  },
50
50
  "dependencies": {
51
- "@opendata-ai/openchart-core": "6.23.1",
51
+ "@opendata-ai/openchart-core": "6.24.1",
52
52
  "d3-array": "^3.2.0",
53
53
  "d3-format": "^3.1.2",
54
54
  "d3-interpolate": "^3.0.0",
@@ -418,7 +418,7 @@ describe('text-aware tick density', () => {
418
418
  expect(axesNarrow.x!.ticks.length).toBeLessThanOrEqual(axesWide.x!.ticks.length);
419
419
  });
420
420
 
421
- it('does not thin x-axis ticks when explicit tickCount is set', () => {
421
+ it('still thins x-axis ticks when tickCount is set but D3 overshoots', () => {
422
422
  const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
423
423
  const specWithTickCount: NormalizedChartSpec = {
424
424
  ...lineSpec,
@@ -431,12 +431,33 @@ describe('text-aware tick density', () => {
431
431
  const scales = computeScales(specWithTickCount, narrowArea, specWithTickCount.data);
432
432
  const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
433
433
 
434
- // With explicit tickCount, the engine should not thin
435
- // D3 may return fewer than 8 for this small dataset, but the point is
436
- // thinTicksUntilFit should not be called
434
+ // tickCount is advisory for D3 - if it overshoots, thinning still applies
435
+ // to prevent overlap. The result should still have ticks, just not more
436
+ // than the narrow area can display without overlap.
437
437
  expect(axes.x!.ticks.length).toBeGreaterThan(0);
438
438
  });
439
439
 
440
+ it('does not thin x-axis ticks when explicit values are set', () => {
441
+ const narrowArea = { x: 50, y: 50, width: 200, height: 300 };
442
+ const specWithValues: NormalizedChartSpec = {
443
+ ...lineSpec,
444
+ encoding: {
445
+ x: {
446
+ field: 'date',
447
+ type: 'temporal',
448
+ axis: { values: ['2020-01-01', '2021-01-01', '2022-01-01'] },
449
+ },
450
+ y: { field: 'value', type: 'quantitative' },
451
+ },
452
+ };
453
+
454
+ const scales = computeScales(specWithValues, narrowArea, specWithValues.data);
455
+ const axes = computeAxes(scales, narrowArea, fullStrategy, theme);
456
+
457
+ // Explicit values should be preserved exactly as specified
458
+ expect(axes.x!.ticks.length).toBe(3);
459
+ });
460
+
440
461
  it('band scale shows all categories regardless of width', () => {
441
462
  const categories = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo'];
442
463
  const barSpec: NormalizedChartSpec = {
@@ -265,4 +265,52 @@ describe('computeDimensions', () => {
265
265
  // Tooltip-only should NOT reserve extra margin (annotations are hidden)
266
266
  expect(dimsTooltipOnly.margins.right).toBe(dimsNoAnnotations.margins.right);
267
267
  });
268
+
269
+ it('clamps y-axis label margin on narrow containers to preserve chart area', () => {
270
+ const longLabelSpec: NormalizedChartSpec = {
271
+ ...baseSpec,
272
+ markType: 'bar',
273
+ markDef: { type: 'bar' },
274
+ data: [
275
+ {
276
+ category: 'This is a very long category label that would consume lots of space',
277
+ value: 10,
278
+ },
279
+ { category: 'Another extremely verbose category name', value: 20 },
280
+ ],
281
+ encoding: {
282
+ x: { field: 'value', type: 'quantitative' },
283
+ y: { field: 'category', type: 'nominal' },
284
+ },
285
+ };
286
+
287
+ const narrowDims = computeDimensions(
288
+ longLabelSpec,
289
+ { width: 350, height: 300 },
290
+ emptyLegend,
291
+ lightTheme,
292
+ );
293
+
294
+ // On narrow viewports, left margin should be clamped so the chart area
295
+ // retains at least ~45% of the container width
296
+ expect(narrowDims.chartArea.width).toBeGreaterThanOrEqual(350 * 0.4);
297
+ });
298
+
299
+ it('tightens legend gap on narrow viewports', () => {
300
+ const wideDims = computeDimensions(
301
+ baseSpec,
302
+ { width: 600, height: 400 },
303
+ topLegend,
304
+ lightTheme,
305
+ );
306
+ const narrowDims = computeDimensions(
307
+ baseSpec,
308
+ { width: 360, height: 400 },
309
+ topLegend,
310
+ lightTheme,
311
+ );
312
+
313
+ // Narrow viewport should have more chart height available (smaller legend gap)
314
+ expect(narrowDims.chartArea.height).toBeGreaterThanOrEqual(wideDims.chartArea.height - 10);
315
+ });
268
316
  });
@@ -276,6 +276,30 @@ describe('computeLegend', () => {
276
276
  expect(deEntry.color).toBe('#00ff00');
277
277
  });
278
278
 
279
+ it('orders legend entries by explicit domain, not data order', () => {
280
+ const specExplicit: NormalizedChartSpec = {
281
+ ...specWithColor,
282
+ data: [
283
+ { date: '2020', value: 10, country: 'Germany' },
284
+ { date: '2021', value: 20, country: 'UK' },
285
+ { date: '2022', value: 30, country: 'US' },
286
+ ],
287
+ encoding: {
288
+ x: { field: 'date', type: 'temporal' },
289
+ y: { field: 'value', type: 'quantitative' },
290
+ color: {
291
+ field: 'country',
292
+ type: 'nominal',
293
+ scale: {
294
+ domain: ['US', 'UK', 'Germany'],
295
+ },
296
+ },
297
+ },
298
+ };
299
+ const legend = computeLegend(specExplicit, compactStrategy, theme, chartArea);
300
+ expect(legend.entries.map((e) => e.label)).toEqual(['US', 'UK', 'Germany']);
301
+ });
302
+
279
303
  it('uses correct swatch shape for chart type', () => {
280
304
  const lineLegend = computeLegend(specWithColor, fullStrategy, theme, chartArea);
281
305
  expect(lineLegend.entries[0].shape).toBe('line');
@@ -330,6 +354,17 @@ describe('computeLegend', () => {
330
354
  expect(legend.entries).toHaveLength(3);
331
355
  });
332
356
 
357
+ it('preserves legend when any legend config is present (e.g. position)', () => {
358
+ const spec: NormalizedChartSpec = {
359
+ ...lineWithLabels,
360
+ legend: { position: 'top' },
361
+ hiddenSeries: [],
362
+ seriesStyles: {},
363
+ };
364
+ const legend = computeLegend(spec, fullStrategy, theme, chartArea);
365
+ expect(legend.entries).toHaveLength(3);
366
+ });
367
+
333
368
  it('preserves legend when labels density is none', () => {
334
369
  const spec: NormalizedChartSpec = {
335
370
  ...lineWithLabels,
@@ -395,32 +430,25 @@ describe('computeLegend', () => {
395
430
  });
396
431
  });
397
432
 
398
- // ---------------------------------------------------------------------------
399
- // Characterization test (refactor/v7-cohesion step 1):
400
- // Pins the 4px gap between a top-positioned legend and the chart area,
401
- // as enforced at packages/engine/src/compile.ts:331. Refactor step 4 will
402
- // consolidate legend row-wrapping geometry; this test guards the spacing
403
- // invariant through that change.
404
- // ---------------------------------------------------------------------------
405
433
  describe('top legend spacing', () => {
406
- it('places the legend exactly 4px above the chart area', () => {
407
- const spec = {
408
- mark: 'bar' as const,
409
- data: [
410
- { name: 'A', value: 10, group: 'X' },
411
- { name: 'A', value: 20, group: 'Y' },
412
- { name: 'B', value: 30, group: 'X' },
413
- { name: 'B', value: 25, group: 'Y' },
414
- ],
415
- encoding: {
416
- x: { field: 'name', type: 'nominal' as const },
417
- y: { field: 'value', type: 'quantitative' as const },
418
- color: { field: 'group', type: 'nominal' as const },
419
- },
420
- legend: { position: 'top' as const },
421
- };
434
+ const topLegendSpec = {
435
+ mark: 'bar' as const,
436
+ data: [
437
+ { name: 'A', value: 10, group: 'X' },
438
+ { name: 'A', value: 20, group: 'Y' },
439
+ { name: 'B', value: 30, group: 'X' },
440
+ { name: 'B', value: 25, group: 'Y' },
441
+ ],
442
+ encoding: {
443
+ x: { field: 'name', type: 'nominal' as const },
444
+ y: { field: 'value', type: 'quantitative' as const },
445
+ color: { field: 'group', type: 'nominal' as const },
446
+ },
447
+ legend: { position: 'top' as const },
448
+ };
422
449
 
423
- const layout = compileChart(spec, { width: 600, height: 400 });
450
+ it('places the legend exactly 4px above the chart area at standard width', () => {
451
+ const layout = compileChart(topLegendSpec, { width: 600, height: 400 });
424
452
 
425
453
  expect(layout.legend.position).toBe('top');
426
454
  expect(layout.legend.entries.length).toBeGreaterThan(0);
@@ -428,8 +456,18 @@ describe('computeLegend', () => {
428
456
 
429
457
  const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
430
458
  const gap = layout.area.y - legendBottom;
431
- // Pin value matches the literal at compile.ts:331 (legendArea.y -= legendLayout.bounds.height + 4)
432
459
  expect(gap).toBe(4);
433
460
  });
461
+
462
+ it('eliminates legend gap on narrow viewports (< 420px)', () => {
463
+ const layout = compileChart(topLegendSpec, { width: 360, height: 400 });
464
+
465
+ expect(layout.legend.position).toBe('top');
466
+ expect(layout.legend.entries.length).toBeGreaterThan(0);
467
+
468
+ const legendBottom = layout.legend.bounds.y + layout.legend.bounds.height;
469
+ const gap = layout.area.y - legendBottom;
470
+ expect(gap).toBe(0);
471
+ });
434
472
  });
435
473
  });
@@ -45,14 +45,15 @@ export function computeAnnotations(
45
45
  obstacles: Rect[] = [],
46
46
  svgDimensions?: { width: number; height: number },
47
47
  ): ResolvedAnnotation[] {
48
- // At compact breakpoints, skip all annotations
49
- if (strategy.annotationPosition === 'tooltip-only') {
50
- return [];
51
- }
48
+ const isCompact = strategy.annotationPosition === 'tooltip-only';
52
49
 
53
50
  const annotations: ResolvedAnnotation[] = [];
54
51
 
55
52
  for (const annotation of spec.annotations) {
53
+ // At compact breakpoints, skip annotations unless they opt out with responsive: false
54
+ if (isCompact && annotation.responsive !== false) {
55
+ continue;
56
+ }
56
57
  let resolved: ResolvedAnnotation | null = null;
57
58
 
58
59
  switch (annotation.type) {
@@ -43,9 +43,11 @@ export function resolveRefLineAnnotation(
43
43
  return null;
44
44
  }
45
45
 
46
- // Determine dash pattern from style
46
+ // Determine dash pattern: strokeDash array wins, then style string
47
47
  let strokeDasharray: string | undefined;
48
- if (annotation.style === 'dashed' || annotation.style === undefined) {
48
+ if (annotation.strokeDash && annotation.strokeDash.length > 0) {
49
+ strokeDasharray = annotation.strokeDash.join(' ');
50
+ } else if (annotation.style === 'dashed' || annotation.style === undefined) {
49
51
  strokeDasharray = DEFAULT_REFLINE_DASH;
50
52
  } else if (annotation.style === 'dotted') {
51
53
  strokeDasharray = '2 2';
@@ -20,6 +20,7 @@ import type {
20
20
  import {
21
21
  buildD3Formatter,
22
22
  estimateTextWidth,
23
+ findAccessibleColor,
23
24
  getRepresentativeColor,
24
25
  resolveCollisions,
25
26
  } from '@opendata-ai/openchart-core';
@@ -137,6 +138,8 @@ export function computeBarLabels(
137
138
 
138
139
  // Determine if label goes inside or outside the bar
139
140
  const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
141
+ const isNegative = Number.isFinite(rawNum) ? rawNum < 0 : false;
142
+ const bgColor = getRepresentativeColor(mark.fill);
140
143
 
141
144
  let anchorX: number;
142
145
  let fill: string;
@@ -145,18 +148,32 @@ export function computeBarLabels(
145
148
  if (isStacked && isInside) {
146
149
  // Stacked: centered within segment
147
150
  anchorX = mark.x + mark.width / 2;
148
- fill = '#ffffff';
151
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
149
152
  textAnchor = 'middle';
150
153
  } else if (isInside) {
151
- // Simple: right-aligned within bar
152
- anchorX = mark.x + mark.width - LABEL_PADDING;
153
- fill = '#ffffff';
154
- textAnchor = 'end';
154
+ if (isNegative) {
155
+ // Negative bar: left-aligned within bar (bar extends leftward)
156
+ anchorX = mark.x + LABEL_PADDING;
157
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
158
+ textAnchor = 'start';
159
+ } else {
160
+ // Positive bar: right-aligned within bar
161
+ anchorX = mark.x + mark.width - LABEL_PADDING;
162
+ fill = findAccessibleColor('#ffffff', bgColor, 4.5);
163
+ textAnchor = 'end';
164
+ }
155
165
  } else {
156
- // Outside: just past the bar's right edge
157
- anchorX = mark.x + mark.width + LABEL_PADDING;
158
- fill = getRepresentativeColor(mark.fill);
159
- textAnchor = 'start';
166
+ if (isNegative) {
167
+ // Outside negative bar: just past the bar's left edge
168
+ anchorX = mark.x - LABEL_PADDING;
169
+ fill = getRepresentativeColor(mark.fill);
170
+ textAnchor = 'end';
171
+ } else {
172
+ // Outside positive bar: just past the bar's right edge
173
+ anchorX = mark.x + mark.width + LABEL_PADDING;
174
+ fill = getRepresentativeColor(mark.fill);
175
+ textAnchor = 'start';
176
+ }
160
177
  }
161
178
 
162
179
  // anchorY = bar vertical center. With dominant-baseline: central,
@@ -264,6 +264,37 @@ describe('computeDotMarks', () => {
264
264
  });
265
265
  });
266
266
 
267
+ describe('baseline clamping', () => {
268
+ it('clamps stems to the plot area when domain does not include zero', () => {
269
+ const spec: NormalizedChartSpec = {
270
+ markType: 'circle',
271
+ markDef: { type: 'circle' },
272
+ data: [
273
+ { country: 'USA', score: 50 },
274
+ { country: 'UK', score: 80 },
275
+ ],
276
+ encoding: {
277
+ x: { field: 'score', type: 'quantitative', scale: { domain: [40, 100] } },
278
+ y: { field: 'country', type: 'nominal' },
279
+ },
280
+ chrome: {},
281
+ annotations: [],
282
+ responsive: true,
283
+ theme: {},
284
+ darkMode: 'off',
285
+ labels: { density: 'auto', format: '' },
286
+ };
287
+ const scales = computeScales(spec, chartArea, spec.data);
288
+ const marks = computeDotMarks(spec, scales, chartArea, fullStrategy);
289
+
290
+ const stems = marks.filter((m): m is RectMark => m.type === 'rect');
291
+ for (const stem of stems) {
292
+ expect(stem.x).toBeGreaterThanOrEqual(chartArea.x);
293
+ expect(stem.x + stem.width).toBeLessThanOrEqual(chartArea.x + chartArea.width);
294
+ }
295
+ });
296
+ });
297
+
267
298
  describe('edge cases', () => {
268
299
  it('returns empty array when no x encoding', () => {
269
300
  const spec: NormalizedChartSpec = {
@@ -67,7 +67,12 @@ export function computeDotMarks(
67
67
  }
68
68
 
69
69
  const bandwidth = yScale.bandwidth();
70
- const baseline = xScale(0);
70
+ // Clamp baseline to the scale range so stems never extend past the plot area
71
+ // (e.g., when domain doesn't include zero, xScale(0) would land outside).
72
+ const [rangeStart, rangeEnd] = xScale.range();
73
+ const rangeMin = Math.min(rangeStart, rangeEnd);
74
+ const rangeMax = Math.max(rangeStart, rangeEnd);
75
+ const baseline = Math.max(rangeMin, Math.min(rangeMax, xScale(0)));
71
76
  const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
72
77
  const isSequentialColor = colorEnc?.type === 'quantitative';
73
78
  const colorField = isSequentialColor ? undefined : colorEnc?.field;
@@ -477,6 +477,34 @@ describe('computeAreaMarks', () => {
477
477
  expect(marks[0].fillOpacity).toBeLessThanOrEqual(1);
478
478
  });
479
479
 
480
+ it('area with y2 encoding uses y2 field as bottom boundary instead of baseline', () => {
481
+ const spec: NormalizedChartSpec = {
482
+ ...makeSingleSeriesSpec(),
483
+ data: [
484
+ { date: '2020-01-01', value: 80, value_low: 60 },
485
+ { date: '2021-01-01', value: 90, value_low: 70 },
486
+ { date: '2022-01-01', value: 85, value_low: 65 },
487
+ ],
488
+ encoding: {
489
+ x: { field: 'date', type: 'temporal' },
490
+ y: { field: 'value', type: 'quantitative' },
491
+ y2: { field: 'value_low', type: 'quantitative' },
492
+ },
493
+ };
494
+ const scales = computeScales(spec, chartArea, spec.data);
495
+ const marks = computeAreaMarks(spec, scales, chartArea);
496
+
497
+ expect(marks).toHaveLength(1);
498
+ // Bottom points should NOT all be at the same baseline y coordinate
499
+ const bottomYValues = marks[0].bottomPoints.map((p) => p.y);
500
+ const allSame = bottomYValues.every((y) => y === bottomYValues[0]);
501
+ expect(allSame).toBe(false);
502
+ // Each bottom point should be between the top point and the chart bottom
503
+ for (let i = 0; i < marks[0].topPoints.length; i++) {
504
+ expect(marks[0].bottomPoints[i].y).toBeGreaterThan(marks[0].topPoints[i].y); // SVG coords: larger y = lower on screen
505
+ }
506
+ });
507
+
480
508
  it('stacked areas: produces multiple AreaMarks for multi-series', () => {
481
509
  const spec = makeMultiSeriesSpec();
482
510
  const scales = computeScales(spec, chartArea, spec.data);
@@ -84,16 +84,24 @@ function computeSingleArea(
84
84
  // Compute points, filtering out null values
85
85
  const validPoints: { x: number; yTop: number; yBottom: number; row: DataRow }[] = [];
86
86
 
87
+ // Check for y2 channel (band between y and y2)
88
+ const y2Channel = (encoding as Encoding & { y2?: { field: string; type: string } }).y2;
89
+
87
90
  for (const row of sortedRows) {
88
91
  const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
89
92
  const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
90
93
 
91
94
  if (xVal === null || yVal === null) continue;
92
95
 
96
+ const yBottomVal =
97
+ y2Channel && row[y2Channel.field] != null
98
+ ? scaleValue(scales.y.scale, scales.y.type, row[y2Channel.field])
99
+ : null;
100
+
93
101
  validPoints.push({
94
102
  x: xVal,
95
103
  yTop: yVal,
96
- yBottom: baselineY,
104
+ yBottom: yBottomVal ?? baselineY,
97
105
  row,
98
106
  });
99
107
  }
@@ -127,6 +135,8 @@ function computeSingleArea(
127
135
 
128
136
  const aria: MarkAria = { label: ariaLabel };
129
137
 
138
+ const fillOpacity = y2Channel ? 0.25 : DEFAULT_FILL_OPACITY;
139
+
130
140
  marks.push({
131
141
  type: 'area',
132
142
  topPoints,
@@ -134,7 +144,7 @@ function computeSingleArea(
134
144
  path: pathStr,
135
145
  topPath: topPathStr,
136
146
  fill: color,
137
- fillOpacity: DEFAULT_FILL_OPACITY,
147
+ fillOpacity: fillOpacity,
138
148
  stroke: getRepresentativeColor(color),
139
149
  strokeWidth: 2,
140
150
  seriesKey: seriesKey === '__default__' ? undefined : seriesKey,
package/src/compile.ts CHANGED
@@ -64,6 +64,7 @@ import { computeDimensions } from './layout/dimensions';
64
64
  import { computeGridlines } from './layout/gridlines';
65
65
  import { computeScales } from './layout/scales';
66
66
  import { computeLegend } from './legend/compute';
67
+ import { legendGap } from './legend/wrap';
67
68
  import { compileSankey as compileSankeyImpl } from './sankey/compile-sankey';
68
69
  import { compileTableLayout } from './tables/compile-table';
69
70
  import { computeTooltipDescriptors } from './tooltips/compute';
@@ -291,13 +292,14 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
291
292
  // the data area (in the margin) instead of overlapping data marks.
292
293
  const legendArea: Rect = { ...chartArea };
293
294
  if (legendLayout.entries.length > 0) {
295
+ const gap = legendGap(options.width);
294
296
  switch (legendLayout.position) {
295
297
  case 'top':
296
- legendArea.y -= legendLayout.bounds.height + 4;
297
- legendArea.height += legendLayout.bounds.height + 4;
298
+ legendArea.y -= legendLayout.bounds.height + gap;
299
+ legendArea.height += legendLayout.bounds.height + gap;
298
300
  break;
299
301
  case 'bottom':
300
- legendArea.height += legendLayout.bounds.height + 4;
302
+ legendArea.height += legendLayout.bounds.height + gap;
301
303
  break;
302
304
  case 'right':
303
305
  case 'bottom-right':
@@ -174,8 +174,10 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation
174
174
  fill: ann.fill ?? '#000000',
175
175
  };
176
176
  case 'refline':
177
+ case 'rule':
177
178
  return {
178
179
  ...ann,
180
+ type: 'refline' as const,
179
181
  style: ann.style ?? 'dashed',
180
182
  strokeWidth: ann.strokeWidth ?? 1,
181
183
  stroke: ann.stroke ?? '#666666',
@@ -272,8 +272,11 @@ export function computeAxes(
272
272
  }));
273
273
 
274
274
  // Thin tick labels to prevent overlap (skip for band scales which use
275
- // auto-rotation, and when the user set an explicit tickCount or values).
276
- const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
275
+ // auto-rotation, and when the user set explicit tick values).
276
+ // When tickCount is set, we still thin if D3 overshot the requested count
277
+ // (common with log scales where ticks(4) can return 26 values).
278
+ const hasExplicitValues = !!axisConfig?.values;
279
+ const shouldThin = scales.x.type !== 'band' && !hasExplicitValues;
277
280
  let ticks: AxisTick[];
278
281
  if (!shouldThin) {
279
282
  ticks = allTicks;
@@ -352,10 +355,12 @@ export function computeAxes(
352
355
  allTicks = continuousTicks(scales.y, yDensity, yTargetCount);
353
356
  }
354
357
 
355
- // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
356
- const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
358
+ // Thin tick labels to prevent overlap (skip for band scales and explicit tick values).
359
+ // When tickCount is set, we still thin if D3 overshot the requested count
360
+ // (common with log scales where ticks(4) can return 26 values).
361
+ const shouldThinY = scales.y.type !== 'band' && !axisConfig?.values;
357
362
  let ticks: AxisTick[];
358
- if (!shouldThin) {
363
+ if (!shouldThinY) {
359
364
  ticks = allTicks;
360
365
  } else if (isContinuousY) {
361
366
  // Continuous y-axis: re-request ticks at a lower count on overlap so
@@ -25,6 +25,7 @@ import { computeChrome, estimateTextWidth } from '@opendata-ai/openchart-core';
25
25
  import { format as d3Format } from 'd3-format';
26
26
 
27
27
  import type { NormalizedChartSpec, NormalizedChrome } from '../compiler/types';
28
+ import { legendGap } from '../legend/wrap';
28
29
 
29
30
  // ---------------------------------------------------------------------------
30
31
  // Types
@@ -167,9 +168,14 @@ export function computeDimensions(
167
168
  };
168
169
 
169
170
  // Dynamic right margin for line/area end-of-line labels.
170
- // Only reserve space when labels will actually render (density != 'none').
171
+ // Only reserve space when labels will actually render.
171
172
  const labelDensity = spec.labels.density;
172
- if ((spec.markType === 'line' || spec.markType === 'area') && labelDensity !== 'none') {
173
+ const labelsHiddenByStrategy = strategy?.labelMode === 'none';
174
+ if (
175
+ (spec.markType === 'line' || spec.markType === 'area') &&
176
+ labelDensity !== 'none' &&
177
+ !labelsHiddenByStrategy
178
+ ) {
173
179
  // Estimate label width from longest series name (color encoding domain)
174
180
  const colorEnc = encoding.color;
175
181
  const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
@@ -251,7 +257,15 @@ export function computeDimensions(
251
257
  if (w > maxLabelWidth) maxLabelWidth = w;
252
258
  }
253
259
  if (maxLabelWidth > 0) {
254
- margins.left = Math.max(margins.left, padding + maxLabelWidth + 12);
260
+ // Tighter label-to-chart gap on narrow containers
261
+ const labelGap = width < 500 ? 8 : 12;
262
+ // Clamp reservation so bars keep at least ~45% of container width on
263
+ // narrow viewports. Labels that exceed the cap will be truncated by
264
+ // the axis renderer (see axes.ts).
265
+ const maxLeftFraction = width < 400 ? 0.45 : width < 600 ? 0.55 : 1;
266
+ const maxLeftReserved = Math.floor(width * maxLeftFraction);
267
+ const reserved = Math.min(padding + maxLabelWidth + labelGap, maxLeftReserved);
268
+ margins.left = Math.max(margins.left, reserved);
255
269
  }
256
270
  } else if (encoding.y.type === 'quantitative' || encoding.y.type === 'temporal') {
257
271
  // Numeric tick labels on the left. Estimate width from the data range.
@@ -305,12 +319,13 @@ export function computeDimensions(
305
319
 
306
320
  // Reserve legend space
307
321
  if (legendLayout.entries.length > 0) {
322
+ const gap = legendGap(width);
308
323
  if (legendLayout.position === 'right' || legendLayout.position === 'bottom-right') {
309
324
  margins.right += legendLayout.bounds.width + 8;
310
325
  } else if (legendLayout.position === 'top') {
311
- margins.top += legendLayout.bounds.height + 4;
326
+ margins.top += legendLayout.bounds.height + gap;
312
327
  } else if (legendLayout.position === 'bottom') {
313
- margins.bottom += legendLayout.bounds.height + 4;
328
+ margins.bottom += legendLayout.bounds.height + gap;
314
329
  }
315
330
  }
316
331
 
@@ -347,10 +362,11 @@ export function computeDimensions(
347
362
  const bottomDelta = margins.bottom - newBottom;
348
363
 
349
364
  if (topDelta > 0 || bottomDelta > 0) {
365
+ const gap = legendGap(width);
350
366
  margins.top =
351
367
  newTop +
352
368
  (legendLayout.entries.length > 0 && legendLayout.position === 'top'
353
- ? legendLayout.bounds.height + 4
369
+ ? legendLayout.bounds.height + gap
354
370
  : 0);
355
371
  margins.bottom = newBottom;
356
372