@opendata-ai/openchart-engine 2.8.1 → 2.9.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
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",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@opendata-ai/openchart-core": "2.
|
|
48
|
+
"@opendata-ai/openchart-core": "2.9.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -136,6 +136,102 @@ describe('computeAnnotations', () => {
|
|
|
136
136
|
expect(annotations[0].fill).toBeDefined();
|
|
137
137
|
expect(annotations[0].opacity).toBeDefined();
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
it('interpolates range position for values between ordinal data points', () => {
|
|
141
|
+
const ordinalSpec: NormalizedChartSpec = {
|
|
142
|
+
type: 'line',
|
|
143
|
+
data: [
|
|
144
|
+
{ year: '2005', value: 10 },
|
|
145
|
+
{ year: '2007', value: 20 },
|
|
146
|
+
{ year: '2009', value: 30 },
|
|
147
|
+
{ year: '2012', value: 40 },
|
|
148
|
+
],
|
|
149
|
+
encoding: {
|
|
150
|
+
x: { field: 'year', type: 'ordinal' },
|
|
151
|
+
y: { field: 'value', type: 'quantitative' },
|
|
152
|
+
},
|
|
153
|
+
chrome: {},
|
|
154
|
+
annotations: [{ type: 'range', x1: '2008', x2: '2010', label: 'Interpolated' }],
|
|
155
|
+
responsive: true,
|
|
156
|
+
theme: {},
|
|
157
|
+
darkMode: 'off',
|
|
158
|
+
labels: { density: 'auto', format: '' },
|
|
159
|
+
};
|
|
160
|
+
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
161
|
+
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
162
|
+
|
|
163
|
+
expect(annotations).toHaveLength(1);
|
|
164
|
+
expect(annotations[0].rect).toBeDefined();
|
|
165
|
+
expect(annotations[0].rect!.width).toBeGreaterThan(0);
|
|
166
|
+
|
|
167
|
+
// Also verify the interpolated range sits between the known data points
|
|
168
|
+
const rangeWithKnownPoints: NormalizedChartSpec = {
|
|
169
|
+
...ordinalSpec,
|
|
170
|
+
annotations: [{ type: 'range', x1: '2007', x2: '2012', label: 'Known' }],
|
|
171
|
+
};
|
|
172
|
+
const knownAnnotations = computeAnnotations(
|
|
173
|
+
rangeWithKnownPoints,
|
|
174
|
+
scales,
|
|
175
|
+
chartArea,
|
|
176
|
+
fullStrategy,
|
|
177
|
+
);
|
|
178
|
+
// The interpolated range (2008-2010) should be narrower than and inside (2007-2012)
|
|
179
|
+
expect(annotations[0].rect!.width).toBeLessThan(knownAnnotations[0].rect!.width);
|
|
180
|
+
expect(annotations[0].rect!.x).toBeGreaterThan(knownAnnotations[0].rect!.x);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('clamps interpolation for values outside the ordinal domain range', () => {
|
|
184
|
+
const ordinalSpec: NormalizedChartSpec = {
|
|
185
|
+
type: 'line',
|
|
186
|
+
data: [
|
|
187
|
+
{ year: '2005', value: 10 },
|
|
188
|
+
{ year: '2007', value: 20 },
|
|
189
|
+
{ year: '2009', value: 30 },
|
|
190
|
+
],
|
|
191
|
+
encoding: {
|
|
192
|
+
x: { field: 'year', type: 'ordinal' },
|
|
193
|
+
y: { field: 'value', type: 'quantitative' },
|
|
194
|
+
},
|
|
195
|
+
chrome: {},
|
|
196
|
+
annotations: [{ type: 'range', x1: '2003', x2: '2011', label: 'Outside range' }],
|
|
197
|
+
responsive: true,
|
|
198
|
+
theme: {},
|
|
199
|
+
darkMode: 'off',
|
|
200
|
+
labels: { density: 'auto', format: '' },
|
|
201
|
+
};
|
|
202
|
+
const scales = computeScales(ordinalSpec, chartArea, ordinalSpec.data);
|
|
203
|
+
const annotations = computeAnnotations(ordinalSpec, scales, chartArea, fullStrategy);
|
|
204
|
+
|
|
205
|
+
expect(annotations).toHaveLength(1);
|
|
206
|
+
expect(annotations[0].rect).toBeDefined();
|
|
207
|
+
expect(annotations[0].rect!.width).toBeGreaterThan(0);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('returns null for non-numeric ordinal domain values', () => {
|
|
211
|
+
const catSpec: NormalizedChartSpec = {
|
|
212
|
+
type: 'column',
|
|
213
|
+
data: [
|
|
214
|
+
{ category: 'Jan', value: 10 },
|
|
215
|
+
{ category: 'Feb', value: 20 },
|
|
216
|
+
{ category: 'Mar', value: 30 },
|
|
217
|
+
],
|
|
218
|
+
encoding: {
|
|
219
|
+
x: { field: 'category', type: 'ordinal' },
|
|
220
|
+
y: { field: 'value', type: 'quantitative' },
|
|
221
|
+
},
|
|
222
|
+
chrome: {},
|
|
223
|
+
annotations: [{ type: 'range', x1: 'Jan-15', x2: 'Feb-15', label: 'Non-numeric' }],
|
|
224
|
+
responsive: true,
|
|
225
|
+
theme: {},
|
|
226
|
+
darkMode: 'off',
|
|
227
|
+
labels: { density: 'auto', format: '' },
|
|
228
|
+
};
|
|
229
|
+
const scales = computeScales(catSpec, chartArea, catSpec.data);
|
|
230
|
+
const annotations = computeAnnotations(catSpec, scales, chartArea, fullStrategy);
|
|
231
|
+
|
|
232
|
+
// Non-numeric domain can't interpolate, annotation is dropped
|
|
233
|
+
expect(annotations).toHaveLength(0);
|
|
234
|
+
});
|
|
139
235
|
});
|
|
140
236
|
|
|
141
237
|
describe('reference line annotations', () => {
|
|
@@ -49,6 +49,42 @@ const DARK_REFLINE_STROKE = '#9ca3af';
|
|
|
49
49
|
/** Default label offset when using anchor directions. */
|
|
50
50
|
const ANCHOR_OFFSET = 8;
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* Interpolate a numeric value between sorted domain entries.
|
|
54
|
+
* Used when an annotation references a value not present in a categorical domain
|
|
55
|
+
* (e.g. "2008" on an axis with data points at "2007" and "2009").
|
|
56
|
+
* Returns null if domain values aren't numeric or the domain is too small.
|
|
57
|
+
*/
|
|
58
|
+
function interpolateInDomain(
|
|
59
|
+
numValue: number,
|
|
60
|
+
domain: string[],
|
|
61
|
+
positionOf: (entry: string) => number,
|
|
62
|
+
): number | null {
|
|
63
|
+
if (domain.length < 2) return null;
|
|
64
|
+
const nums = domain.map(Number);
|
|
65
|
+
if (!nums.every(Number.isFinite)) return null;
|
|
66
|
+
|
|
67
|
+
// Sort by numeric value so bracket-finding works regardless of data order
|
|
68
|
+
const sorted = nums.map((n, i) => ({ n, i })).sort((a, b) => a.n - b.n);
|
|
69
|
+
|
|
70
|
+
// Find the two sorted neighbors that bracket this value
|
|
71
|
+
let lower = 0;
|
|
72
|
+
let upper = sorted.length - 1;
|
|
73
|
+
for (let i = 0; i < sorted.length; i++) {
|
|
74
|
+
if (sorted[i].n <= numValue) lower = i;
|
|
75
|
+
if (sorted[i].n >= numValue) {
|
|
76
|
+
upper = i;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const lowerPos = positionOf(domain[sorted[lower].i]);
|
|
82
|
+
const upperPos = positionOf(domain[sorted[upper].i]);
|
|
83
|
+
if (lower === upper) return lowerPos;
|
|
84
|
+
const t = (numValue - sorted[lower].n) / (sorted[upper].n - sorted[lower].n);
|
|
85
|
+
return lowerPos + t * (upperPos - lowerPos);
|
|
86
|
+
}
|
|
87
|
+
|
|
52
88
|
/** Resolve a data value to a pixel position on a given axis. */
|
|
53
89
|
function resolvePosition(
|
|
54
90
|
value: string | number,
|
|
@@ -73,17 +109,33 @@ function resolvePosition(
|
|
|
73
109
|
|
|
74
110
|
if (type === 'band') {
|
|
75
111
|
const bandScale = s as ScaleBand<string>;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
return pos + (bandScale.bandwidth?.() ?? 0) / 2;
|
|
112
|
+
const strValue = String(value);
|
|
113
|
+
const pos = bandScale(strValue);
|
|
114
|
+
if (pos !== undefined) return pos + (bandScale.bandwidth?.() ?? 0) / 2;
|
|
115
|
+
|
|
116
|
+
const bw = bandScale.bandwidth?.() ?? 0;
|
|
117
|
+
return interpolateInDomain(
|
|
118
|
+
Number(strValue),
|
|
119
|
+
bandScale.domain(),
|
|
120
|
+
(entry) => (bandScale(entry) ?? 0) + bw / 2,
|
|
121
|
+
);
|
|
79
122
|
}
|
|
80
123
|
|
|
81
|
-
// point or ordinal: try
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
124
|
+
// point or ordinal: try direct lookup, fall back to interpolation
|
|
125
|
+
const strValue = String(value);
|
|
126
|
+
const directResult = (s as (v: string) => number | undefined)(strValue);
|
|
127
|
+
if (directResult !== undefined) return directResult;
|
|
128
|
+
|
|
129
|
+
if (type === 'point' || type === 'ordinal') {
|
|
130
|
+
const domain = (s as { domain(): string[] }).domain();
|
|
131
|
+
return interpolateInDomain(
|
|
132
|
+
Number(strValue),
|
|
133
|
+
domain,
|
|
134
|
+
(entry) => (s as (v: string) => number)(entry) ?? 0,
|
|
135
|
+
);
|
|
86
136
|
}
|
|
137
|
+
|
|
138
|
+
return null;
|
|
87
139
|
}
|
|
88
140
|
|
|
89
141
|
function makeAnnotationLabelStyle(
|