@opendata-ai/openchart-engine 2.8.1 → 2.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "2.8.1",
3
+ "version": "2.9.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",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@opendata-ai/openchart-core": "2.8.1",
48
+ "@opendata-ai/openchart-core": "2.9.1",
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 pos = bandScale(String(value));
77
- if (pos === undefined) return null;
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 calling it directly
82
- try {
83
- return (s as (v: string) => number)(String(value));
84
- } catch {
85
- return null;
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(