@opendata-ai/openchart-engine 7.0.4 → 7.1.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.
@@ -230,7 +230,10 @@ describe('compileChart', () => {
230
230
 
231
231
  expect(light.theme.isDark).toBe(false);
232
232
  expect(dark.theme.isDark).toBe(true);
233
- expect(dark.theme.colors.background).not.toBe(light.theme.colors.background);
233
+ // Both modes preserve transparent background — dark mode swaps text/axis/gridline
234
+ // colors but keeps transparency so the host surface shows through.
235
+ expect(dark.theme.colors.background).toBe('transparent');
236
+ expect(light.theme.colors.background).toBe('transparent');
234
237
  // Dark mode text should be light, light mode text should be dark
235
238
  expect(dark.theme.colors.text).not.toBe(light.theme.colors.text);
236
239
  });
@@ -142,7 +142,12 @@ describe('computeDimensions', () => {
142
142
 
143
143
  expect(lightDims.theme.isDark).toBe(false);
144
144
  expect(darkDims.theme.isDark).toBe(true);
145
- expect(darkDims.theme.colors.background).not.toBe(lightDims.theme.colors.background);
145
+ // Both modes use transparent background — the dark adaptation changes text/axis
146
+ // colors while keeping transparency so the host surface shows through.
147
+ expect(darkDims.theme.colors.background).toBe('transparent');
148
+ expect(lightDims.theme.colors.background).toBe('transparent');
149
+ // Dark mode uses a different text color
150
+ expect(darkDims.theme.colors.text).not.toBe(lightDims.theme.colors.text);
146
151
  });
147
152
 
148
153
  it('prevents negative chart area dimensions', () => {
@@ -32,7 +32,7 @@ import { formatLabelValue } from '../_shared/format-label-value';
32
32
 
33
33
  const LABEL_FONT_SIZE = 10;
34
34
  const LABEL_FONT_WEIGHT = 600;
35
- const LABEL_OFFSET_Y = 6;
35
+ const LABEL_OFFSET_Y = 8;
36
36
 
37
37
  // ---------------------------------------------------------------------------
38
38
  // Public API
@@ -91,8 +91,11 @@ export function computeColumnLabels(
91
91
  const textWidth = estimateTextWidth(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
92
92
  const textHeight = LABEL_FONT_SIZE * 1.2;
93
93
 
94
- // For positive values, place label above the column top.
95
- // For negative values, place label below the column bottom.
94
+ // anchorY is the TOP of the label bounding box so the collision system's
95
+ // AABB check (rect = { y: anchorY, height: textHeight }) is geometrically
96
+ // correct. dominantBaseline 'hanging' anchors the glyph top at anchorY.
97
+ // Positive bar: top = barTop - LABEL_OFFSET_Y - textHeight, text floats above
98
+ // Negative bar: top = barBottom + LABEL_OFFSET_Y, text hangs below
96
99
  const anchorX = mark.x + mark.width / 2;
97
100
  const anchorY = isNegative
98
101
  ? mark.y + mark.height + LABEL_OFFSET_Y
@@ -112,7 +115,7 @@ export function computeColumnLabels(
112
115
  fill: labelColor ?? getRepresentativeColor(mark.fill),
113
116
  lineHeight: 1.2,
114
117
  textAnchor: 'middle',
115
- dominantBaseline: isNegative ? 'hanging' : 'auto',
118
+ dominantBaseline: 'hanging',
116
119
  },
117
120
  });
118
121
  }
@@ -620,12 +620,31 @@ describe('computeAreaMarks', () => {
620
620
  expect(fill.gradient).toBe('linear');
621
621
  expect(fill.stops).toHaveLength(2);
622
622
  expect(fill.stops[0].opacity).toBe(0.65);
623
- expect(fill.stops[1].opacity).toBe(0.35);
623
+ // Light mode: bottom fades to 0 so the colored wash at the base is avoided
624
+ expect(fill.stops[1].opacity).toBe(0);
624
625
  // fillOpacity should be 1 so gradient stop-opacity controls the fade
625
626
  expect(mark.fillOpacity).toBe(1);
626
627
  }
627
628
  });
628
629
 
630
+ it('stacked areas use higher bottom opacity in dark mode', () => {
631
+ const spec = makeMultiSeriesSpec();
632
+ spec.encoding.y!.stack = 'zero';
633
+ const scales = computeScales(spec, chartArea, spec.data);
634
+ const marks = computeAreaMarks(spec, scales, chartArea, true /* darkMode */);
635
+
636
+ expect(marks.length).toBeGreaterThan(0);
637
+ for (const mark of marks) {
638
+ const fill = mark.fill as { gradient: string; stops: { opacity?: number }[] };
639
+ expect(fill.gradient).toBe('linear');
640
+ expect(fill.stops).toHaveLength(2);
641
+ expect(fill.stops[0].opacity).toBe(0.65);
642
+ // Dark mode: bottom stop is 0.35 so bands remain visible on dark surfaces
643
+ expect(fill.stops[1].opacity).toBe(0.35);
644
+ expect(mark.fillOpacity).toBe(1);
645
+ }
646
+ });
647
+
629
648
  it('stacked: markDef.fill string still overrides per-layer gradient', () => {
630
649
  const spec = makeMultiSeriesSpec();
631
650
  spec.encoding.y!.stack = 'zero';
@@ -80,6 +80,13 @@ const STACKED_GRADIENT_STOPS = [
80
80
  { offset: 1, opacity: 0.35 },
81
81
  ];
82
82
 
83
+ // Light-mode stacked areas: bottom out at opacity 0 rather than 0.35 to avoid
84
+ // the muddy color wash at the base of each band on white/light backgrounds.
85
+ const STACKED_GRADIENT_STOPS_LIGHT = [
86
+ { offset: 0, opacity: 0.65 },
87
+ { offset: 1, opacity: 0 },
88
+ ];
89
+
83
90
  function buildGradientFill(
84
91
  colorStr: string,
85
92
  stops: ReadonlyArray<{ offset: number; opacity: number }>,
@@ -257,6 +264,7 @@ function computeStackedArea(
257
264
  spec: NormalizedChartSpec,
258
265
  scales: ResolvedScales,
259
266
  chartArea: Rect,
267
+ darkMode?: boolean,
260
268
  ): AreaMark[] {
261
269
  const encoding = spec.encoding as Encoding;
262
270
  const xChannel = encoding.x;
@@ -389,7 +397,8 @@ function computeStackedArea(
389
397
  fillOpacity = isGradientDef(markFill) ? 1 : (spec.markDef.opacity ?? 0.7);
390
398
  } else {
391
399
  const colorStr = getRepresentativeColor(color);
392
- fillValue = buildGradientFill(colorStr, STACKED_GRADIENT_STOPS);
400
+ const stackedStops = darkMode ? STACKED_GRADIENT_STOPS : STACKED_GRADIENT_STOPS_LIGHT;
401
+ fillValue = buildGradientFill(colorStr, stackedStops);
393
402
  fillOpacity = 1;
394
403
  }
395
404
 
@@ -443,12 +452,13 @@ export function computeAreaMarks(
443
452
  spec: NormalizedChartSpec,
444
453
  scales: ResolvedScales,
445
454
  chartArea: Rect,
455
+ darkMode?: boolean,
446
456
  ): AreaMark[] {
447
457
  const encoding = spec.encoding as Encoding;
448
458
  const yChannel = encoding.y;
449
459
 
450
460
  if (yChannel && isStacked(yChannel.stack)) {
451
- return computeStackedArea(spec, scales, chartArea);
461
+ return computeStackedArea(spec, scales, chartArea, darkMode);
452
462
  }
453
463
 
454
464
  return computeSingleArea(spec, scales, chartArea);
@@ -71,8 +71,8 @@ export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _
71
71
  * of whether the layout is stacked (cumulative tops) or overlap (per-series
72
72
  * raw values).
73
73
  */
74
- export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
75
- const areas = computeAreaMarks(spec, scales, chartArea);
74
+ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => {
75
+ const areas = computeAreaMarks(spec, scales, chartArea, theme.isDark);
76
76
 
77
77
  const encoding = spec.encoding;
78
78
  const hasColor = !!(encoding.color && 'field' in encoding.color);
@@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest';
19
19
 
20
20
  import type { NormalizedChartSpec } from '../../compiler/types';
21
21
  import { bidirectionalSweep, computeEndpointLabels } from '../compute';
22
+ import { ENDPOINT_MARKER_RADIUS } from '../constants';
22
23
 
23
24
  // ---------------------------------------------------------------------------
24
25
  // Fixtures
@@ -281,7 +282,10 @@ describe('computeEndpointLabels', () => {
281
282
 
282
283
  for (const entry of layout.entries) {
283
284
  expect(entry.marker).toBeDefined();
284
- expect(entry.marker!.x).toBe(lastX);
285
+ // dataX is the original line endpoint; x is offset right by radius so the
286
+ // line terminates at the circle edge rather than its center.
287
+ expect(entry.marker!.dataX).toBe(lastX);
288
+ expect(entry.marker!.x).toBe(lastX + ENDPOINT_MARKER_RADIUS);
285
289
  // Marker y is at the actual data point (not displaced labelY).
286
290
  expect(entry.marker!.y).toBe(entry.dataY);
287
291
  // Open-circle convention: fill = background, stroke = series color.
@@ -386,9 +386,12 @@ export function computeEndpointLabels(
386
386
  showLeader: showLeader && displaced,
387
387
  };
388
388
  if (showMarker) {
389
+ // Offset cx right by markerRadius so the line terminates at the circle's
390
+ // left edge rather than its center — prevents the line from piercing the ring.
389
391
  entry.marker = {
390
- x: p.dataX,
392
+ x: p.dataX + markerRadius,
391
393
  y: p.dataY,
394
+ dataX: p.dataX,
392
395
  fill: markerFill,
393
396
  stroke: config?.markerStyle?.stroke ?? p.color,
394
397
  strokeWidth: markerStrokeWidth,
@@ -12,9 +12,11 @@ import { estimateTextWidth } from '@opendata-ai/openchart-core';
12
12
 
13
13
  /**
14
14
  * Minimum gap between adjacent tick labels as a multiple of font size.
15
- * At the default 12px axis font, this yields ~12px of breathing room.
15
+ * At the default 11px axis font, this yields ~5-6px of breathing room.
16
+ * Reduced from 1.0 to 0.5 to prevent over-aggressive thinning on charts
17
+ * with a small number of categories that clearly have room for all labels.
16
18
  */
17
- const MIN_TICK_GAP_FACTOR = 1.0;
19
+ const MIN_TICK_GAP_FACTOR = 0.5;
18
20
 
19
21
  /** Always show at least this many ticks, even if they overlap. */
20
22
  const MIN_TICK_COUNT = 2;
@@ -37,6 +37,7 @@ import {
37
37
  MAX_LEFT_LABEL_FRACTION_MEDIUM,
38
38
  MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
39
39
  NARROW_VIEWPORT_MAX,
40
+ TICK_LABEL_OFFSET,
40
41
  TOP_PAD_EXTRA_NARROW,
41
42
  } from '@opendata-ai/openchart-core';
42
43
  import { format as d3Format } from 'd3-format';
@@ -93,6 +94,16 @@ function chromeToInput(chrome: NormalizedChrome): import('@opendata-ai/openchart
93
94
  };
94
95
  }
95
96
 
97
+ /**
98
+ * Compute the bottom margin contribution from chrome.
99
+ * chrome.bottomHeight already includes its own padding when it has content
100
+ * (watermark, source, byline, or footer). When zero, fall back to the base
101
+ * padding so the chart area doesn't butt against the container edge.
102
+ */
103
+ function bottomMargin(bottomHeight: number, padding: number, xAxisHeight: number): number {
104
+ return (bottomHeight > 0 ? bottomHeight : padding) + xAxisHeight;
105
+ }
106
+
96
107
  /**
97
108
  * Scale padding based on the smaller container dimension.
98
109
  * At >= 500px, padding is unchanged. At <= 200px, padding is halved (min 4px).
@@ -370,7 +381,7 @@ export function computeDimensions(
370
381
  const margins: Margins = {
371
382
  top: topPad + chrome.topHeight + tentativeMetricsHeight,
372
383
  right: hPad + (isRadial ? hPad : axisMargin),
373
- bottom: padding + chrome.bottomHeight + xAxisHeight,
384
+ bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight),
374
385
  left: hPad + (isRadial ? hPad : axisMargin),
375
386
  };
376
387
 
@@ -580,10 +591,53 @@ export function computeDimensions(
580
591
  }
581
592
 
582
593
  // Rotated y-axis label needs extra left margin (rendered at area.x - offset in SVG).
583
- // Tighter on compact viewports where horizontal space is scarce.
594
+ // The renderer computes a dynamic offset that accounts for wide tick labels (e.g.
595
+ // "$100,000" is ~62px wide and would overlap a fixed 45px offset). We replicate
596
+ // the same formula here so the reserved space matches what the renderer places.
584
597
  const yAxis = encoding.y?.axis as Record<string, unknown> | undefined;
585
598
  if (yAxis && (yAxis.title || yAxis.label) && !isRadial) {
586
- const axisTitleOffset = getAxisTitleOffset(width);
599
+ // Estimate the widest y-axis tick label width to mirror the renderer's dynamic offset.
600
+ const yFieldForTitle = encoding.y?.field;
601
+ const yAxisFormatForTitle = yAxis?.format as string | undefined;
602
+ let estTickLabelWidth = 0;
603
+ if (
604
+ yFieldForTitle &&
605
+ (encoding.y?.type === 'quantitative' || encoding.y?.type === 'temporal')
606
+ ) {
607
+ let maxAbsValForTitle = 0;
608
+ for (const row of spec.data) {
609
+ const v = Number(row[yFieldForTitle]);
610
+ if (Number.isFinite(v) && Math.abs(v) > maxAbsValForTitle) maxAbsValForTitle = Math.abs(v);
611
+ }
612
+ let sampleLabelForTitle: string;
613
+ if (yAxisFormatForTitle) {
614
+ try {
615
+ const fmt = d3Format(yAxisFormatForTitle);
616
+ sampleLabelForTitle = fmt(maxAbsValForTitle);
617
+ } catch {
618
+ sampleLabelForTitle = String(maxAbsValForTitle);
619
+ }
620
+ } else {
621
+ if (maxAbsValForTitle >= 1_000_000_000) sampleLabelForTitle = '1.5B';
622
+ else if (maxAbsValForTitle >= 1_000_000) sampleLabelForTitle = '1.5M';
623
+ else if (maxAbsValForTitle >= 1_000) sampleLabelForTitle = '1.5K';
624
+ else if (maxAbsValForTitle >= 100) sampleLabelForTitle = '100';
625
+ else if (maxAbsValForTitle >= 10) sampleLabelForTitle = '10';
626
+ else sampleLabelForTitle = '0.0';
627
+ }
628
+ const negPrefixForTitle = spec.data.some((r) => Number(r[yFieldForTitle]) < 0) ? '-' : '';
629
+ estTickLabelWidth = estimateTextWidth(
630
+ negPrefixForTitle + sampleLabelForTitle,
631
+ theme.fonts.sizes.axisTick,
632
+ theme.fonts.weights.normal,
633
+ );
634
+ }
635
+ // Mirror the renderer's dynamic offset formula:
636
+ // dynamicOffset = TICK_LABEL_OFFSET(6) + maxTickLabelWidth + 8px gap
637
+ // titleOffset = max(dynamicOffset, AXIS_TITLE_OFFSET_COMPACT)
638
+ const AXIS_TITLE_GAP = 8;
639
+ const dynamicTitleOffset = TICK_LABEL_OFFSET + estTickLabelWidth + AXIS_TITLE_GAP;
640
+ const axisTitleOffset = Math.max(dynamicTitleOffset, getAxisTitleOffset(width));
587
641
  const halfGlyph = Math.ceil(theme.fonts.sizes.body / 2);
588
642
  const rotatedLabelMargin =
589
643
  axisTitleOffset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
@@ -655,7 +709,7 @@ export function computeDimensions(
655
709
  isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin + inlineTickOverhang;
656
710
  const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight;
657
711
  const topDelta = margins.top - newTop;
658
- const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
712
+ const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
659
713
  const bottomDelta = margins.bottom - newBottom;
660
714
 
661
715
  if (topDelta > 0 || bottomDelta > 0) {