@opendata-ai/openchart-engine 6.24.0 → 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 +44 -16
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +11 -0
- 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/line/__tests__/compute.test.ts +28 -0
- package/src/charts/line/area.ts +12 -2
- package/src/compiler/normalize.ts +2 -0
- package/src/layout/dimensions.ts +7 -2
- package/src/legend/compute.ts +31 -17
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.24.
|
|
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.24.
|
|
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",
|
|
@@ -354,6 +354,17 @@ describe('computeLegend', () => {
|
|
|
354
354
|
expect(legend.entries).toHaveLength(3);
|
|
355
355
|
});
|
|
356
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
|
+
|
|
357
368
|
it('preserves legend when labels density is none', () => {
|
|
358
369
|
const spec: NormalizedChartSpec = {
|
|
359
370
|
...lineWithLabels,
|
|
@@ -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,
|
|
@@ -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,
|
|
@@ -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/dimensions.ts
CHANGED
|
@@ -168,9 +168,14 @@ export function computeDimensions(
|
|
|
168
168
|
};
|
|
169
169
|
|
|
170
170
|
// Dynamic right margin for line/area end-of-line labels.
|
|
171
|
-
// Only reserve space when labels will actually render
|
|
171
|
+
// Only reserve space when labels will actually render.
|
|
172
172
|
const labelDensity = spec.labels.density;
|
|
173
|
-
|
|
173
|
+
const labelsHiddenByStrategy = strategy?.labelMode === 'none';
|
|
174
|
+
if (
|
|
175
|
+
(spec.markType === 'line' || spec.markType === 'area') &&
|
|
176
|
+
labelDensity !== 'none' &&
|
|
177
|
+
!labelsHiddenByStrategy
|
|
178
|
+
) {
|
|
174
179
|
// Estimate label width from longest series name (color encoding domain)
|
|
175
180
|
const colorEnc = encoding.color;
|
|
176
181
|
const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
|
package/src/legend/compute.ts
CHANGED
|
@@ -83,21 +83,25 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
83
83
|
]
|
|
84
84
|
: dataValues;
|
|
85
85
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
86
|
+
const excludeSet = new Set(spec.legend?.exclude ?? []);
|
|
87
|
+
|
|
88
|
+
return uniqueValues
|
|
89
|
+
.map((value, i) => {
|
|
90
|
+
// When explicit domain+range are provided, look up the color by domain index
|
|
91
|
+
// so legend colors match the mark colors exactly.
|
|
92
|
+
let colorIndex = i;
|
|
93
|
+
if (explicitDomain && explicitRange) {
|
|
94
|
+
const domainIdx = explicitDomain.indexOf(value);
|
|
95
|
+
if (domainIdx >= 0) colorIndex = domainIdx;
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
label: value,
|
|
99
|
+
color: palette[colorIndex % palette.length],
|
|
100
|
+
shape,
|
|
101
|
+
active: true,
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((entry) => !excludeSet.has(entry.label));
|
|
101
105
|
}
|
|
102
106
|
|
|
103
107
|
/**
|
|
@@ -162,12 +166,22 @@ export function computeLegend(
|
|
|
162
166
|
|
|
163
167
|
// Auto-suppress legend when endpoint labels identify series on line/area charts.
|
|
164
168
|
// Guards: keep legend at compact breakpoints (labels hidden), for stacked areas
|
|
165
|
-
// (endpoint labels overlap), and when user
|
|
169
|
+
// (endpoint labels overlap), and when user has configured any legend property
|
|
170
|
+
// (position, columns, maxRows, etc.) — any explicit legend config signals intent
|
|
171
|
+
// to show a legend, not just show: true.
|
|
166
172
|
const isLineOrArea = spec.markType === 'line' || spec.markType === 'area';
|
|
167
173
|
const hasLabels = spec.labels.density !== 'none';
|
|
168
174
|
const labelsWillRender = strategy.labelMode !== 'none';
|
|
169
175
|
const hasColorEncoding = spec.encoding.color != null;
|
|
170
|
-
|
|
176
|
+
// Legend is "forced" when the user set show: true OR specified any legend config
|
|
177
|
+
// other than show: false. Vega-Lite convention: legend is shown by default for
|
|
178
|
+
// multi-series charts; auto-suppression only fires when no legend config is present.
|
|
179
|
+
const userConfiguredLegend =
|
|
180
|
+
spec.legend != null &&
|
|
181
|
+
Object.keys(spec.legend).some(
|
|
182
|
+
(k) => k !== 'show' || spec.legend![k as keyof typeof spec.legend] !== false,
|
|
183
|
+
);
|
|
184
|
+
const legendNotForced = !userConfiguredLegend;
|
|
171
185
|
|
|
172
186
|
if (isLineOrArea && hasLabels && labelsWillRender && hasColorEncoding && legendNotForced) {
|
|
173
187
|
const isArea = spec.markType === 'area';
|