@opendata-ai/openchart-engine 7.4.0 → 7.5.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": "7.
|
|
3
|
+
"version": "7.5.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",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"typecheck": "tsc --noEmit"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@opendata-ai/openchart-core": "7.
|
|
51
|
+
"@opendata-ai/openchart-core": "7.5.0",
|
|
52
52
|
"d3-array": "^3.2.0",
|
|
53
53
|
"d3-format": "^3.1.2",
|
|
54
54
|
"d3-interpolate": "^3.0.0",
|
|
@@ -548,4 +548,58 @@ describe('computeLegend', () => {
|
|
|
548
548
|
expect(legend.rowHeight).toBeGreaterThan(0);
|
|
549
549
|
});
|
|
550
550
|
});
|
|
551
|
+
|
|
552
|
+
describe('rows missing the color field', () => {
|
|
553
|
+
// A layered spec flattens every layer's rows into `spec.data`, but the
|
|
554
|
+
// color encoding belongs to one layer. Rows from sibling layers (e.g. a
|
|
555
|
+
// diagonal reference-line) have no value for the color field and must not
|
|
556
|
+
// manufacture a phantom "undefined" legend entry.
|
|
557
|
+
it('ignores rows whose color field is undefined', () => {
|
|
558
|
+
const spec: NormalizedChartSpec = {
|
|
559
|
+
...specWithColor,
|
|
560
|
+
data: [
|
|
561
|
+
{ date: '2020', value: 10, country: 'US' },
|
|
562
|
+
{ date: '2021', value: 20, country: 'UK' },
|
|
563
|
+
// rule-layer rows: no `country` field
|
|
564
|
+
{ date: '2020', value: 0 },
|
|
565
|
+
{ date: '2022', value: 40 },
|
|
566
|
+
],
|
|
567
|
+
};
|
|
568
|
+
const legend = computeLegend(spec, fullStrategy, theme, chartArea);
|
|
569
|
+
expect(legend.entries.map((e) => e.label)).toEqual(['US', 'UK']);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it('keeps an explicit color domain authoritative despite phantom rows', () => {
|
|
573
|
+
const spec: NormalizedChartSpec = {
|
|
574
|
+
...specWithColor,
|
|
575
|
+
data: [
|
|
576
|
+
{ date: '2020', value: 10, tier: 'One F from takeover' },
|
|
577
|
+
{ date: '2021', value: 20, tier: 'One year behind' },
|
|
578
|
+
{ date: '2022', value: 30, tier: 'Other D/F campus' },
|
|
579
|
+
// diagonal reference-line layer, no `tier`
|
|
580
|
+
{ date: '2020', value: 0 },
|
|
581
|
+
{ date: '2022', value: 40 },
|
|
582
|
+
],
|
|
583
|
+
encoding: {
|
|
584
|
+
x: { field: 'date', type: 'temporal' },
|
|
585
|
+
y: { field: 'value', type: 'quantitative' },
|
|
586
|
+
color: {
|
|
587
|
+
field: 'tier',
|
|
588
|
+
type: 'nominal',
|
|
589
|
+
scale: {
|
|
590
|
+
domain: ['One F from takeover', 'One year behind', 'Other D/F campus'],
|
|
591
|
+
range: ['#fb7185', '#f59e0b', '#64748b'],
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
const legend = computeLegend(spec, fullStrategy, theme, chartArea);
|
|
597
|
+
expect(legend.entries.map((e) => e.label)).toEqual([
|
|
598
|
+
'One F from takeover',
|
|
599
|
+
'One year behind',
|
|
600
|
+
'Other D/F campus',
|
|
601
|
+
]);
|
|
602
|
+
expect(legend.entries).toHaveLength(3);
|
|
603
|
+
});
|
|
604
|
+
});
|
|
551
605
|
});
|
|
@@ -179,6 +179,40 @@ describe('computeScatterMarks', () => {
|
|
|
179
179
|
|
|
180
180
|
expect(marks[0].aria.label).toContain('population=');
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
it('explicit size domain keeps the largest datum below the max radius', () => {
|
|
184
|
+
const base = makeBubbleSpec();
|
|
185
|
+
// Widen the domain past the data max (8000) so nothing reaches radius 30.
|
|
186
|
+
const spec: NormalizedChartSpec = {
|
|
187
|
+
...base,
|
|
188
|
+
encoding: {
|
|
189
|
+
...base.encoding,
|
|
190
|
+
size: { field: 'population', type: 'quantitative', scale: { domain: [0, 32000] } },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
194
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
195
|
+
|
|
196
|
+
const largest = marks.find((m) => m.data.population === 8000)!;
|
|
197
|
+
// sqrt(8000/32000) * 30 = 0.5 * 30 = 15, well under the 30px cap.
|
|
198
|
+
expect(largest.r).toBeLessThan(20);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('explicit size range caps the radii', () => {
|
|
202
|
+
const base = makeBubbleSpec();
|
|
203
|
+
const spec: NormalizedChartSpec = {
|
|
204
|
+
...base,
|
|
205
|
+
encoding: {
|
|
206
|
+
...base.encoding,
|
|
207
|
+
size: { field: 'population', type: 'quantitative', scale: { range: [2, 12] } },
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
211
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
212
|
+
|
|
213
|
+
const maxRadius = Math.max(...marks.map((m) => m.r));
|
|
214
|
+
expect(maxRadius).toBeLessThanOrEqual(12);
|
|
215
|
+
});
|
|
182
216
|
});
|
|
183
217
|
|
|
184
218
|
describe('color encoding', () => {
|
|
@@ -113,19 +113,24 @@ export function computeScatterMarks(
|
|
|
113
113
|
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
114
114
|
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
115
115
|
const colorField = colorEnc?.field;
|
|
116
|
-
const
|
|
116
|
+
const sizeEnc = encoding.size && 'field' in encoding.size ? encoding.size : undefined;
|
|
117
|
+
const sizeField = sizeEnc?.field;
|
|
117
118
|
|
|
118
|
-
// Build a size scale for bubble variant
|
|
119
|
+
// Build a size scale for bubble variant. Domain and radius range are
|
|
120
|
+
// author-overridable via `encoding.size.scale.{domain,range}`: an explicit
|
|
121
|
+
// domain (e.g. [0, 900]) keeps the largest datum below the max radius so
|
|
122
|
+
// dense clusters don't blob together, and an explicit range caps the radii.
|
|
119
123
|
let sizeScale: ((v: number) => number) | undefined;
|
|
120
124
|
if (sizeField) {
|
|
121
125
|
const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));
|
|
122
126
|
|
|
123
|
-
const
|
|
124
|
-
const sizeMax = max(sizeValues) ?? 1;
|
|
127
|
+
const explicitDomain = sizeEnc?.scale?.domain as [number, number] | undefined;
|
|
128
|
+
const [sizeMin, sizeMax] = explicitDomain ?? [min(sizeValues) ?? 0, max(sizeValues) ?? 1];
|
|
125
129
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
130
|
+
const explicitRange = sizeEnc?.scale?.range as [number, number] | undefined;
|
|
131
|
+
const [radiusMin, radiusMax] = explicitRange ?? [MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS];
|
|
132
|
+
|
|
133
|
+
sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([radiusMin, radiusMax]).clamp(true);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
const marks: PointMark[] = [];
|
package/src/legend/compute.ts
CHANGED
|
@@ -144,7 +144,15 @@ function extractColorEntries(spec: NormalizedChartSpec, theme: ResolvedTheme): L
|
|
|
144
144
|
// Sequential (quantitative) color doesn't produce discrete legend entries
|
|
145
145
|
if (colorEnc.type === 'quantitative') return [];
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
// Rows missing the color field (e.g. a sibling layer in a layered spec whose
|
|
148
|
+
// marks don't participate in the color encoding) must not manufacture a legend
|
|
149
|
+
// category. Without this guard, `String(undefined)` seeds a phantom "undefined"
|
|
150
|
+
// entry and — with an explicit domain — appends it past the authored entries.
|
|
151
|
+
const dataValues = [
|
|
152
|
+
...new Set(
|
|
153
|
+
spec.data.filter((d) => d[colorEnc.field] != null).map((d) => String(d[colorEnc.field])),
|
|
154
|
+
),
|
|
155
|
+
];
|
|
148
156
|
const explicitDomain = colorEnc.scale?.domain as string[] | undefined;
|
|
149
157
|
const explicitRange = colorEnc.scale?.range as string[] | undefined;
|
|
150
158
|
const palette = explicitRange ?? theme.colors.categorical;
|