@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/dist/index.js +148 -84
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/axes.test.ts +25 -4
- package/src/__tests__/dimensions.test.ts +48 -0
- package/src/__tests__/legend.test.ts +63 -25
- package/src/annotations/compute.ts +5 -4
- package/src/annotations/resolve-refline.ts +4 -2
- package/src/charts/bar/labels.ts +26 -9
- package/src/charts/dot/__tests__/compute.test.ts +31 -0
- package/src/charts/dot/compute.ts +6 -1
- package/src/charts/line/__tests__/compute.test.ts +28 -0
- package/src/charts/line/area.ts +12 -2
- package/src/compile.ts +5 -3
- package/src/compiler/normalize.ts +2 -0
- package/src/layout/axes.ts +10 -5
- package/src/layout/dimensions.ts +22 -6
- package/src/legend/compute.ts +66 -26
- package/src/legend/wrap.ts +13 -2
- package/src/sankey/compile-sankey.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
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.
|
|
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('
|
|
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
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
46
|
+
// Determine dash pattern: strokeDash array wins, then style string
|
|
47
47
|
let strokeDasharray: string | undefined;
|
|
48
|
-
if (annotation.
|
|
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';
|
package/src/charts/bar/labels.ts
CHANGED
|
@@ -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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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);
|
package/src/charts/line/area.ts
CHANGED
|
@@ -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:
|
|
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 +
|
|
297
|
-
legendArea.height += legendLayout.bounds.height +
|
|
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 +
|
|
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',
|
package/src/layout/axes.ts
CHANGED
|
@@ -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
|
|
276
|
-
|
|
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
|
|
356
|
-
|
|
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 (!
|
|
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
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -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
|
|
171
|
+
// Only reserve space when labels will actually render.
|
|
171
172
|
const labelDensity = spec.labels.density;
|
|
172
|
-
|
|
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
|
-
|
|
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 +
|
|
326
|
+
margins.top += legendLayout.bounds.height + gap;
|
|
312
327
|
} else if (legendLayout.position === 'bottom') {
|
|
313
|
-
margins.bottom += legendLayout.bounds.height +
|
|
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 +
|
|
369
|
+
? legendLayout.bounds.height + gap
|
|
354
370
|
: 0);
|
|
355
371
|
margins.bottom = newBottom;
|
|
356
372
|
|