@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.
- package/dist/index.js +57 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +106 -102
- package/src/__tests__/compile-chart.test.ts +4 -1
- package/src/__tests__/dimensions.test.ts +6 -1
- package/src/charts/column/labels.ts +7 -4
- package/src/charts/line/__tests__/compute.test.ts +20 -1
- package/src/charts/line/area.ts +12 -2
- package/src/charts/line/index.ts +2 -2
- package/src/endpoint-labels/__tests__/compute.test.ts +5 -1
- package/src/endpoint-labels/compute.ts +4 -1
- package/src/layout/axes/thinning.ts +4 -2
- package/src/layout/dimensions.ts +58 -4
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
95
|
-
//
|
|
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:
|
|
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
|
-
|
|
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';
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/charts/line/index.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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;
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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 =
|
|
712
|
+
const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight);
|
|
659
713
|
const bottomDelta = margins.bottom - newBottom;
|
|
660
714
|
|
|
661
715
|
if (topDelta > 0 || bottomDelta > 0) {
|