@opendata-ai/openchart-engine 7.2.0 → 7.2.2
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 +56 -20
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +3 -3
- package/src/__tests__/compile-chart.test.ts +53 -0
- package/src/__tests__/scales.test.ts +34 -0
- package/src/annotations/__tests__/compute.test.ts +64 -0
- package/src/annotations/resolve-range.ts +23 -6
- package/src/charts/line/index.ts +53 -3
- package/src/compile.ts +19 -9
- package/src/layout/dimensions.ts +10 -10
- package/src/layout/scales.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "7.2.
|
|
3
|
+
"version": "7.2.2",
|
|
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": "7.2.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.2.2",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -462,7 +462,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data
|
|
|
462
462
|
"chromeToChart": 8,
|
|
463
463
|
"padding": 20,
|
|
464
464
|
"xAxisHeight": 26,
|
|
465
|
-
"xAxisLabelPadding":
|
|
465
|
+
"xAxisLabelPadding": 4,
|
|
466
466
|
},
|
|
467
467
|
},
|
|
468
468
|
"tooltipDescriptors": [
|
|
@@ -1438,7 +1438,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line
|
|
|
1438
1438
|
"chromeToChart": 8,
|
|
1439
1439
|
"padding": 20,
|
|
1440
1440
|
"xAxisHeight": 26,
|
|
1441
|
-
"xAxisLabelPadding":
|
|
1441
|
+
"xAxisLabelPadding": 4,
|
|
1442
1442
|
},
|
|
1443
1443
|
},
|
|
1444
1444
|
"tooltipDescriptors": [],
|
|
@@ -2029,7 +2029,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g
|
|
|
2029
2029
|
"chromeToChart": 8,
|
|
2030
2030
|
"padding": 20,
|
|
2031
2031
|
"xAxisHeight": 26,
|
|
2032
|
-
"xAxisLabelPadding":
|
|
2032
|
+
"xAxisLabelPadding": 4,
|
|
2033
2033
|
},
|
|
2034
2034
|
},
|
|
2035
2035
|
"tooltipDescriptors": [
|
|
@@ -175,6 +175,32 @@ describe('compileChart', () => {
|
|
|
175
175
|
expect(layout.legend.position).toBe('top');
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
+
it('aligns the top legend left edge to the plot area, not the container edge', () => {
|
|
179
|
+
// A y-axis title + numeric ticks reserves a left gutter, pushing the plot
|
|
180
|
+
// area right of the container padding. The top legend should start at the
|
|
181
|
+
// plot's left edge (above the y-axis guide), not flush against the container.
|
|
182
|
+
const layout = compileChart(
|
|
183
|
+
{
|
|
184
|
+
...lineSpec,
|
|
185
|
+
encoding: {
|
|
186
|
+
...lineSpec.encoding,
|
|
187
|
+
y: { field: 'value', type: 'quantitative' as const, axis: { title: 'GDP ($B)' } },
|
|
188
|
+
},
|
|
189
|
+
legend: { position: 'top' as const, show: true },
|
|
190
|
+
},
|
|
191
|
+
{ width: 360, height: 400 },
|
|
192
|
+
);
|
|
193
|
+
expect(layout.legend.position).toBe('top');
|
|
194
|
+
expect(layout.legend.entries.length).toBeGreaterThan(0);
|
|
195
|
+
// Plot area is inset from the container by the y-axis gutter.
|
|
196
|
+
expect(layout.area.x).toBeGreaterThan(0);
|
|
197
|
+
// Legend left edge now matches the plot left edge (within a pixel).
|
|
198
|
+
expect(layout.legend.bounds.x).toBeCloseTo(layout.area.x, 0);
|
|
199
|
+
// And the legend never paints past the container's right padding edge.
|
|
200
|
+
// Default theme spacing.padding is 20.
|
|
201
|
+
expect(layout.legend.bounds.x + layout.legend.bounds.width).toBeLessThanOrEqual(360 - 20 + 0.5);
|
|
202
|
+
});
|
|
203
|
+
|
|
178
204
|
it('produces line marks with dataPoints (no PointMarks by default)', () => {
|
|
179
205
|
const layout = compileChart(lineSpec, { width: 600, height: 400 });
|
|
180
206
|
expect(layout.marks.length).toBeGreaterThan(0);
|
|
@@ -205,6 +231,33 @@ describe('compileChart', () => {
|
|
|
205
231
|
expect(pointMarks.length).toBeGreaterThan(0);
|
|
206
232
|
});
|
|
207
233
|
|
|
234
|
+
it('produces PointMarks on a multi-series area when mark.point is true', () => {
|
|
235
|
+
// Multi-series areas derive their lines from area tops, which bypasses the
|
|
236
|
+
// line renderer's point emission. mark.point must still place dots.
|
|
237
|
+
const areaSpec = {
|
|
238
|
+
...lineSpec,
|
|
239
|
+
mark: { type: 'area' as const, point: true as const },
|
|
240
|
+
};
|
|
241
|
+
const layout = compileChart(areaSpec, { width: 600, height: 400 });
|
|
242
|
+
|
|
243
|
+
const areaMarks = layout.marks.filter((m) => m.type === 'area');
|
|
244
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
245
|
+
expect(areaMarks.length).toBe(2); // US + UK
|
|
246
|
+
// One point per data row across both series (2 points x 2 series).
|
|
247
|
+
expect(pointMarks.length).toBe(4);
|
|
248
|
+
for (const p of pointMarks) {
|
|
249
|
+
if (p.type === 'point') expect(p.r).toBeGreaterThan(0);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('does not produce PointMarks on a multi-series area by default', () => {
|
|
254
|
+
const areaSpec = { ...lineSpec, mark: 'area' as const };
|
|
255
|
+
const layout = compileChart(areaSpec, { width: 600, height: 400 });
|
|
256
|
+
|
|
257
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
258
|
+
expect(pointMarks.length).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
208
261
|
it('includes accessibility metadata with meaningful content', () => {
|
|
209
262
|
const layout = compileChart(lineSpec, { width: 600, height: 400 });
|
|
210
263
|
expect(layout.a11y.altText).toContain('Line chart');
|
|
@@ -269,6 +269,40 @@ describe('scale config properties', () => {
|
|
|
269
269
|
expect(paddedBandwidth).toBeGreaterThan(defaultBandwidth);
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
+
it('point scale honors paddingOuter as a padding alias', () => {
|
|
273
|
+
const ordinalBase = {
|
|
274
|
+
...lineSpec,
|
|
275
|
+
data: [
|
|
276
|
+
{ year: 'A', value: 10 },
|
|
277
|
+
{ year: 'B', value: 20 },
|
|
278
|
+
{ year: 'C', value: 30 },
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
const wide: NormalizedChartSpec = {
|
|
282
|
+
...ordinalBase,
|
|
283
|
+
encoding: {
|
|
284
|
+
x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.5 } },
|
|
285
|
+
y: { field: 'value', type: 'quantitative' },
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
const tight: NormalizedChartSpec = {
|
|
289
|
+
...ordinalBase,
|
|
290
|
+
encoding: {
|
|
291
|
+
x: { field: 'year', type: 'ordinal', scale: { paddingOuter: 0.04 } },
|
|
292
|
+
y: { field: 'value', type: 'quantitative' },
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const wideScales = computeScales(wide, chartArea, wide.data);
|
|
297
|
+
const tightScales = computeScales(tight, chartArea, tight.data);
|
|
298
|
+
expect(wideScales.x!.type).toBe('point');
|
|
299
|
+
|
|
300
|
+
// Less outer padding pushes the first point closer to the left edge.
|
|
301
|
+
const wideFirst = wideScales.x!.scale('A') as number;
|
|
302
|
+
const tightFirst = tightScales.x!.scale('A') as number;
|
|
303
|
+
expect(tightFirst).toBeLessThan(wideFirst);
|
|
304
|
+
});
|
|
305
|
+
|
|
272
306
|
it('backward compatible: existing specs still work', () => {
|
|
273
307
|
// lineSpec and barSpec from before should still produce valid scales
|
|
274
308
|
const lineScales = computeScales(lineSpec, chartArea, lineSpec.data);
|
|
@@ -225,6 +225,35 @@ describe('computeAnnotations', () => {
|
|
|
225
225
|
expect(annotations[0].label!.text).toBe('Recession');
|
|
226
226
|
});
|
|
227
227
|
|
|
228
|
+
it('range label defaults to 11px / weight 500', () => {
|
|
229
|
+
const spec = makeSpec([
|
|
230
|
+
{ type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' },
|
|
231
|
+
]);
|
|
232
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
233
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
234
|
+
|
|
235
|
+
expect(annotations[0].label!.style.fontSize).toBe(11);
|
|
236
|
+
expect(annotations[0].label!.style.fontWeight).toBe(500);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('range label honors fontSize and fontWeight overrides', () => {
|
|
240
|
+
const spec = makeSpec([
|
|
241
|
+
{
|
|
242
|
+
type: 'range',
|
|
243
|
+
x1: '2020-01-01',
|
|
244
|
+
x2: '2021-01-01',
|
|
245
|
+
label: 'Period',
|
|
246
|
+
fontSize: 19,
|
|
247
|
+
fontWeight: 600,
|
|
248
|
+
},
|
|
249
|
+
]);
|
|
250
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
251
|
+
const annotations = computeAnnotations(spec, scales, chartArea, fullStrategy);
|
|
252
|
+
|
|
253
|
+
expect(annotations[0].label!.style.fontSize).toBe(19);
|
|
254
|
+
expect(annotations[0].label!.style.fontWeight).toBe(600);
|
|
255
|
+
});
|
|
256
|
+
|
|
228
257
|
it('range has fill and opacity', () => {
|
|
229
258
|
const spec = makeSpec([
|
|
230
259
|
{
|
|
@@ -463,6 +492,41 @@ describe('computeAnnotations', () => {
|
|
|
463
492
|
expect(rect.x + rect.width).toBeCloseTo(x2BandStart + bandwidth, 1);
|
|
464
493
|
});
|
|
465
494
|
|
|
495
|
+
it('extendToEdges:false anchors a point-scale range at data point centers', () => {
|
|
496
|
+
// With extendToEdges:false the band starts/ends exactly at the first/last
|
|
497
|
+
// data point centers instead of extending half a step to the plot edge,
|
|
498
|
+
// so the range is inset from the axis.
|
|
499
|
+
const domainValues = ['2006', '2008', '2010', '2012'];
|
|
500
|
+
const ordinalSpec: NormalizedChartSpec = {
|
|
501
|
+
markType: 'line',
|
|
502
|
+
markDef: { type: 'line' },
|
|
503
|
+
data: domainValues.map((year, i) => ({ year, value: i * 10 })),
|
|
504
|
+
encoding: {
|
|
505
|
+
x: { field: 'year', type: 'ordinal' },
|
|
506
|
+
y: { field: 'value', type: 'quantitative' },
|
|
507
|
+
},
|
|
508
|
+
chrome: {},
|
|
509
|
+
annotations: [{ type: 'range', x1: '2006', x2: '2012', extendToEdges: false }],
|
|
510
|
+
responsive: true,
|
|
511
|
+
theme: {},
|
|
512
|
+
darkMode: 'off',
|
|
513
|
+
labels: { density: 'auto', format: '' },
|
|
514
|
+
};
|
|
515
|
+
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
516
|
+
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
517
|
+
|
|
518
|
+
expect(annotations).toHaveLength(1);
|
|
519
|
+
const rect = annotations[0].rect!;
|
|
520
|
+
|
|
521
|
+
const xScale = scales.x!.scale as ScalePoint<string>;
|
|
522
|
+
// Left/right edges land at the point centers, not extended past them.
|
|
523
|
+
expect(rect.x).toBeCloseTo(xScale('2006')!, 1);
|
|
524
|
+
expect(rect.x + rect.width).toBeCloseTo(xScale('2012')!, 1);
|
|
525
|
+
// And the band is strictly inside the plot (inset from both edges).
|
|
526
|
+
expect(rect.x).toBeGreaterThan(chartArea.x);
|
|
527
|
+
expect(rect.x + rect.width).toBeLessThan(chartArea.x + chartArea.width);
|
|
528
|
+
});
|
|
529
|
+
|
|
466
530
|
it('linear-scale range is unaffected by edge extension', () => {
|
|
467
531
|
// For linear scales, resolvePositionEdge is identical to resolvePosition.
|
|
468
532
|
// This ensures the fix doesn't introduce any drift on continuous axes.
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
import type { ResolvedScales } from '../layout/scales';
|
|
13
13
|
import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants';
|
|
14
14
|
import { applyOffset } from './geometry';
|
|
15
|
-
import { resolvePositionEdge } from './position';
|
|
15
|
+
import { resolvePosition, resolvePositionEdge } from './position';
|
|
16
16
|
import { makeAnnotationLabelStyle } from './resolve-text';
|
|
17
17
|
|
|
18
18
|
export function resolveRangeAnnotation(
|
|
@@ -26,10 +26,22 @@ export function resolveRangeAnnotation(
|
|
|
26
26
|
let width = chartArea.width;
|
|
27
27
|
let height = chartArea.height;
|
|
28
28
|
|
|
29
|
+
// When extendToEdges is false, anchor at the data point's exact position
|
|
30
|
+
// (band/point center) instead of extending to the band/step edge. This insets
|
|
31
|
+
// the range from the axis so it starts at the first data point rather than
|
|
32
|
+
// flush against the axis guide. No effect on linear/time scales.
|
|
33
|
+
const extend = annotation.extendToEdges !== false;
|
|
34
|
+
const resolveEdge = (
|
|
35
|
+
value: string | number,
|
|
36
|
+
scale: typeof scales.x,
|
|
37
|
+
edge: 'start' | 'end',
|
|
38
|
+
): number | null =>
|
|
39
|
+
extend ? resolvePositionEdge(value, scale, edge) : resolvePosition(value, scale);
|
|
40
|
+
|
|
29
41
|
// X-range (vertical band)
|
|
30
42
|
if (annotation.x1 !== undefined && annotation.x2 !== undefined) {
|
|
31
|
-
const x1px =
|
|
32
|
-
const x2px =
|
|
43
|
+
const x1px = resolveEdge(annotation.x1, scales.x, 'start');
|
|
44
|
+
const x2px = resolveEdge(annotation.x2, scales.x, 'end');
|
|
33
45
|
if (x1px === null || x2px === null) return null;
|
|
34
46
|
|
|
35
47
|
x = Math.min(x1px, x2px);
|
|
@@ -38,8 +50,8 @@ export function resolveRangeAnnotation(
|
|
|
38
50
|
|
|
39
51
|
// Y-range (horizontal band)
|
|
40
52
|
if (annotation.y1 !== undefined && annotation.y2 !== undefined) {
|
|
41
|
-
const y1px =
|
|
42
|
-
const y2px =
|
|
53
|
+
const y1px = resolveEdge(annotation.y1, scales.y, 'end');
|
|
54
|
+
const y2px = resolveEdge(annotation.y2, scales.y, 'start');
|
|
43
55
|
if (y1px === null || y2px === null) return null;
|
|
44
56
|
|
|
45
57
|
y = Math.min(y1px, y2px);
|
|
@@ -62,7 +74,12 @@ export function resolveRangeAnnotation(
|
|
|
62
74
|
const baseDy = 14;
|
|
63
75
|
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
64
76
|
|
|
65
|
-
const style = makeAnnotationLabelStyle(
|
|
77
|
+
const style = makeAnnotationLabelStyle(
|
|
78
|
+
annotation.fontSize ?? 11,
|
|
79
|
+
annotation.fontWeight ?? 500,
|
|
80
|
+
undefined,
|
|
81
|
+
isDark,
|
|
82
|
+
);
|
|
66
83
|
if (centered) {
|
|
67
84
|
style.textAnchor = 'middle';
|
|
68
85
|
} else if (anchor === 'right') {
|
package/src/charts/line/index.ts
CHANGED
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
* `'normalize'`, or `'center'`.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { AreaMark, LineMark, Mark } from '@opendata-ai/openchart-core';
|
|
13
|
+
import type { AreaMark, LineMark, Mark, PointMark } from '@opendata-ai/openchart-core';
|
|
14
14
|
import { getRepresentativeColor } from '@opendata-ai/openchart-core';
|
|
15
|
+
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
15
16
|
import type { ChartRenderer } from '../registry';
|
|
16
17
|
import { computeAreaMarks } from './area';
|
|
17
18
|
import { computeLineMarks } from './compute';
|
|
18
19
|
import { computeLineLabels } from './labels';
|
|
19
20
|
|
|
21
|
+
/** Radius for area-chart data points, matching the line renderer. */
|
|
22
|
+
const AREA_POINT_RADIUS = 3;
|
|
23
|
+
|
|
20
24
|
// ---------------------------------------------------------------------------
|
|
21
25
|
// Line chart renderer
|
|
22
26
|
// ---------------------------------------------------------------------------
|
|
@@ -84,8 +88,14 @@ export const areaRenderer: ChartRenderer = (spec, scales, chartArea, strategy, t
|
|
|
84
88
|
? linesFromAreas(areas)
|
|
85
89
|
: computeLineMarks(spec, scales, chartArea, strategy);
|
|
86
90
|
|
|
87
|
-
//
|
|
88
|
-
|
|
91
|
+
// For multi-series areas the lines are derived from area tops, which skips
|
|
92
|
+
// the point-emission path in computeLineMarks. Emit the data-point dots here
|
|
93
|
+
// so `mark.point` works on area charts the same way it does on lines.
|
|
94
|
+
// Single-series areas already get their points from computeLineMarks above.
|
|
95
|
+
const points = hasColor && spec.markDef.point ? pointsFromAreas(areas, spec.markDef.point) : [];
|
|
96
|
+
|
|
97
|
+
// Areas go first (rendered behind lines), then lines, then points on top
|
|
98
|
+
return [...areas, ...lines, ...points] as Mark[];
|
|
89
99
|
};
|
|
90
100
|
|
|
91
101
|
// ---------------------------------------------------------------------------
|
|
@@ -117,6 +127,46 @@ function linesFromAreas(areas: AreaMark[]): LineMark[] {
|
|
|
117
127
|
}));
|
|
118
128
|
}
|
|
119
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Derive PointMark[] sitting on each area's top boundary. Honors the same
|
|
132
|
+
* `mark.point` modes as the line renderer: `true` (filled dots), `'transparent'`
|
|
133
|
+
* (invisible hover targets), and `'endpoints'` (hollow dots at first/last only).
|
|
134
|
+
*/
|
|
135
|
+
function pointsFromAreas(
|
|
136
|
+
areas: AreaMark[],
|
|
137
|
+
pointMode: NonNullable<NormalizedChartSpec['markDef']['point']>,
|
|
138
|
+
): PointMark[] {
|
|
139
|
+
const isTransparent = pointMode === 'transparent';
|
|
140
|
+
const isEndpoints = pointMode === 'endpoints';
|
|
141
|
+
const points: PointMark[] = [];
|
|
142
|
+
|
|
143
|
+
for (const a of areas) {
|
|
144
|
+
const stroke = getRepresentativeColor(a.fill);
|
|
145
|
+
const lastIdx = a.topPoints.length - 1;
|
|
146
|
+
|
|
147
|
+
for (let i = 0; i < a.topPoints.length; i++) {
|
|
148
|
+
const pt = a.topPoints[i];
|
|
149
|
+
const isEndpoint = i === 0 || i === lastIdx;
|
|
150
|
+
const visible = !isTransparent && (!isEndpoints || isEndpoint);
|
|
151
|
+
const hollow = isEndpoints && visible;
|
|
152
|
+
points.push({
|
|
153
|
+
type: 'point',
|
|
154
|
+
cx: pt.x,
|
|
155
|
+
cy: pt.y,
|
|
156
|
+
r: visible ? AREA_POINT_RADIUS : 0,
|
|
157
|
+
fill: hollow ? 'transparent' : stroke,
|
|
158
|
+
stroke: hollow ? stroke : visible ? '#ffffff' : 'transparent',
|
|
159
|
+
strokeWidth: visible ? 1.5 : 0,
|
|
160
|
+
fillOpacity: isTransparent ? 0 : 1,
|
|
161
|
+
data: a.data[i] ?? {},
|
|
162
|
+
aria: { decorative: true },
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return points;
|
|
168
|
+
}
|
|
169
|
+
|
|
120
170
|
// ---------------------------------------------------------------------------
|
|
121
171
|
// Public exports
|
|
122
172
|
// ---------------------------------------------------------------------------
|
package/src/compile.ts
CHANGED
|
@@ -553,24 +553,34 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
553
553
|
// the reserved margin. This way computeLegend positions the legend outside
|
|
554
554
|
// the data area (in the margin) instead of overlapping data marks.
|
|
555
555
|
//
|
|
556
|
-
// Top/bottom legends
|
|
557
|
-
//
|
|
558
|
-
// the
|
|
559
|
-
//
|
|
560
|
-
// the
|
|
556
|
+
// Top/bottom legends align their left edge to the plot area (chartArea.x),
|
|
557
|
+
// so the legend sits above/below the y-axis guide rather than flush against
|
|
558
|
+
// the container edge.
|
|
559
|
+
//
|
|
560
|
+
// Width runs from the new left origin (chartArea.x) to the container's right
|
|
561
|
+
// padding, so the rendered legend never paints past the right edge / over the
|
|
562
|
+
// endpoint-label column. This is narrower than the first pass (which used the
|
|
563
|
+
// full container at preliminaryArea.width = options.width), so on a chart with
|
|
564
|
+
// BOTH a wide y-axis gutter AND a legend that nearly fills its row, the second
|
|
565
|
+
// pass can wrap to one more row than the first pass reserved in margins.top —
|
|
566
|
+
// the extra row protrudes slightly into the chrome gap. That graceful failure
|
|
567
|
+
// (a few px of vertical crowding, still on-canvas) is preferable to the
|
|
568
|
+
// alternative (chips clipped off the right edge of the SVG). The maxRows cap
|
|
569
|
+
// (default 2 for top legends) bounds the worst case to a single extra row.
|
|
561
570
|
const legendArea: Rect = { ...chartArea };
|
|
562
571
|
if ('entries' in legendLayout && legendLayout.entries.length > 0) {
|
|
572
|
+
const legendInnerWidth = options.width - theme.spacing.padding - chartArea.x;
|
|
563
573
|
const gap = legendGap(options.width);
|
|
564
574
|
switch (legendLayout.position) {
|
|
565
575
|
case 'top':
|
|
566
|
-
legendArea.x =
|
|
567
|
-
legendArea.width =
|
|
576
|
+
legendArea.x = chartArea.x;
|
|
577
|
+
legendArea.width = legendInnerWidth;
|
|
568
578
|
legendArea.y -= legendLayout.bounds.height + gap;
|
|
569
579
|
legendArea.height += legendLayout.bounds.height + gap;
|
|
570
580
|
break;
|
|
571
581
|
case 'bottom':
|
|
572
|
-
legendArea.x =
|
|
573
|
-
legendArea.width =
|
|
582
|
+
legendArea.x = chartArea.x;
|
|
583
|
+
legendArea.width = legendInnerWidth;
|
|
574
584
|
// Bottom legend sits below the x-axis tick row, not over it. Expand
|
|
575
585
|
// legendArea by xAxisHeight + legendHeight + gap so the bottom-anchored
|
|
576
586
|
// legend lands beneath the axis. Mirrors dimensions.ts which reserved
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -23,12 +23,11 @@ import type {
|
|
|
23
23
|
ResolvedTheme,
|
|
24
24
|
} from '@opendata-ai/openchart-core';
|
|
25
25
|
import {
|
|
26
|
-
AXIS_TITLE_GAP,
|
|
27
26
|
AXIS_TITLE_TRAILING_PAD,
|
|
27
|
+
axisTitleOffset,
|
|
28
28
|
BREAKPOINT_COMPACT_MAX,
|
|
29
29
|
computeChrome,
|
|
30
30
|
estimateTextWidth,
|
|
31
|
-
getAxisTitleOffset,
|
|
32
31
|
HPAD_COMPACT_FRACTION,
|
|
33
32
|
HPAD_COMPACT_MIN,
|
|
34
33
|
LABEL_GAP_COMPACT,
|
|
@@ -38,7 +37,6 @@ import {
|
|
|
38
37
|
MAX_LEFT_LABEL_FRACTION_MEDIUM,
|
|
39
38
|
MAX_LEFT_LABEL_FRACTION_MEDIUM_MAX,
|
|
40
39
|
NARROW_VIEWPORT_MAX,
|
|
41
|
-
TICK_LABEL_OFFSET,
|
|
42
40
|
TOP_PAD_EXTRA_NARROW,
|
|
43
41
|
} from '@opendata-ai/openchart-core';
|
|
44
42
|
import { format as d3Format } from 'd3-format';
|
|
@@ -634,14 +632,16 @@ export function computeDimensions(
|
|
|
634
632
|
theme.fonts.weights.normal,
|
|
635
633
|
);
|
|
636
634
|
}
|
|
637
|
-
// Mirror the renderer's
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
const
|
|
635
|
+
// Mirror the renderer's title placement so the reserved space matches where
|
|
636
|
+
// the title is drawn. axisTitleOffset() returns the distance to the title's
|
|
637
|
+
// center; it already includes the title half-glyph on the tick-label side.
|
|
638
|
+
// We add another halfGlyph here for the title glyph extending the other way,
|
|
639
|
+
// toward the container edge, so the margin reaches the title's outer edge.
|
|
640
|
+
const titleFontSize = theme.fonts.sizes.body;
|
|
641
|
+
const offset = axisTitleOffset(estTickLabelWidth, titleFontSize, width);
|
|
642
|
+
const halfGlyph = Math.ceil(titleFontSize / 2);
|
|
643
643
|
const rotatedLabelMargin =
|
|
644
|
-
|
|
644
|
+
offset + halfGlyph + (width < BREAKPOINT_COMPACT_MAX ? 0 : AXIS_TITLE_TRAILING_PAD);
|
|
645
645
|
margins.left = Math.max(margins.left, hPad + rotatedLabelMargin);
|
|
646
646
|
}
|
|
647
647
|
|
package/src/layout/scales.ts
CHANGED
|
@@ -500,7 +500,11 @@ function buildPointScale(
|
|
|
500
500
|
? (channel.scale.domain as string[])
|
|
501
501
|
: applyCategoricalSort(uniqueStrings(fieldValues(data, channel.field)), channel.sort);
|
|
502
502
|
|
|
503
|
-
|
|
503
|
+
// Point scales have a single padding knob (outer padding only -- there are no
|
|
504
|
+
// bands, so paddingInner is meaningless). Accept `paddingOuter` as an alias so
|
|
505
|
+
// a spec written for a band scale doesn't silently no-op when the mark is a
|
|
506
|
+
// line; explicit `padding` wins if both are set.
|
|
507
|
+
const padding = channel.scale?.padding ?? channel.scale?.paddingOuter ?? 0.5;
|
|
504
508
|
const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(padding);
|
|
505
509
|
|
|
506
510
|
if (channel.scale?.reverse) {
|