@opendata-ai/openchart-engine 7.2.1 → 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 +24 -18
- 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 +26 -0
- package/src/annotations/__tests__/compute.test.ts +64 -0
- package/src/annotations/resolve-range.ts +23 -6
- package/src/compile.ts +19 -9
- package/src/layout/dimensions.ts +10 -10
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);
|
|
@@ -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/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
|
|