@opendata-ai/openchart-engine 6.1.5 → 6.2.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/dist/index.js +31 -14
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/graphs/__tests__/compile-graph.test.ts +56 -0
- package/src/graphs/compile-graph.ts +28 -13
- package/src/graphs/encoding.ts +44 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.2.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": "6.
|
|
48
|
+
"@opendata-ai/openchart-core": "6.2.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -141,6 +141,62 @@ describe('compileGraph', () => {
|
|
|
141
141
|
});
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
describe('with explicit scale domain/range', () => {
|
|
145
|
+
it('uses explicit domain and range for nominal nodeColor', () => {
|
|
146
|
+
const spec = {
|
|
147
|
+
...makeBasicGraphSpec(),
|
|
148
|
+
encoding: {
|
|
149
|
+
nodeColor: {
|
|
150
|
+
field: 'group',
|
|
151
|
+
type: 'nominal' as const,
|
|
152
|
+
scale: {
|
|
153
|
+
domain: ['X', 'Y'],
|
|
154
|
+
range: ['#ff0000', '#00ff00'],
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
const result = compileGraph(spec, compileOptions);
|
|
160
|
+
|
|
161
|
+
const xNode = result.nodes.find((n) => n.data.group === 'X')!;
|
|
162
|
+
const yNode = result.nodes.find((n) => n.data.group === 'Y')!;
|
|
163
|
+
expect(xNode.fill).toBe('#ff0000');
|
|
164
|
+
expect(yNode.fill).toBe('#00ff00');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('falls back to auto-derived domain when scale is omitted', () => {
|
|
168
|
+
const result = compileGraph(makeEncodedGraphSpec(), compileOptions);
|
|
169
|
+
|
|
170
|
+
const xNode = result.nodes.find((n) => n.data.group === 'X')!;
|
|
171
|
+
const yNode = result.nodes.find((n) => n.data.group === 'Y')!;
|
|
172
|
+
// Should still produce different colors (auto-derived)
|
|
173
|
+
expect(xNode.fill).not.toBe(yNode.fill);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('explicit domain controls color ordering', () => {
|
|
177
|
+
// Reversed domain order should swap colors
|
|
178
|
+
const spec = {
|
|
179
|
+
...makeBasicGraphSpec(),
|
|
180
|
+
encoding: {
|
|
181
|
+
nodeColor: {
|
|
182
|
+
field: 'group',
|
|
183
|
+
type: 'nominal' as const,
|
|
184
|
+
scale: {
|
|
185
|
+
domain: ['Y', 'X'],
|
|
186
|
+
range: ['#ff0000', '#00ff00'],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const result = compileGraph(spec, compileOptions);
|
|
192
|
+
|
|
193
|
+
const xNode = result.nodes.find((n) => n.data.group === 'X')!;
|
|
194
|
+
const yNode = result.nodes.find((n) => n.data.group === 'Y')!;
|
|
195
|
+
expect(xNode.fill).toBe('#00ff00');
|
|
196
|
+
expect(yNode.fill).toBe('#ff0000');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
144
200
|
describe('with community clustering', () => {
|
|
145
201
|
it('assigns communities to nodes', () => {
|
|
146
202
|
const result = compileGraph(makeClusteredGraphSpec(), compileOptions);
|
|
@@ -54,6 +54,7 @@ function buildGraphLegend(
|
|
|
54
54
|
communityColorMap: Map<string, string>,
|
|
55
55
|
hasCommunities: boolean,
|
|
56
56
|
theme: ResolvedTheme,
|
|
57
|
+
nodeColorField?: string,
|
|
57
58
|
): LegendLayout {
|
|
58
59
|
const labelStyle: TextStyle = {
|
|
59
60
|
fontFamily: theme.fonts.family,
|
|
@@ -74,20 +75,24 @@ function buildGraphLegend(
|
|
|
74
75
|
active: true,
|
|
75
76
|
}));
|
|
76
77
|
} else {
|
|
77
|
-
//
|
|
78
|
-
|
|
78
|
+
// Build legend from nodeColor encoding: group by the color field value
|
|
79
|
+
// so each legend entry shows the categorical value (e.g. "Dataset", "bls")
|
|
80
|
+
// rather than an arbitrary node label.
|
|
81
|
+
const categoryColors = new Map<string, string>();
|
|
79
82
|
for (const node of nodes) {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
const category = nodeColorField
|
|
84
|
+
? String(node.data[nodeColorField] ?? node.label ?? node.id)
|
|
85
|
+
: (node.label ?? node.id);
|
|
86
|
+
if (!categoryColors.has(category)) {
|
|
87
|
+
categoryColors.set(category, node.fill);
|
|
83
88
|
}
|
|
84
89
|
}
|
|
85
90
|
|
|
86
|
-
// Only show legend if there are multiple
|
|
87
|
-
if (
|
|
91
|
+
// Only show legend if there are multiple categories
|
|
92
|
+
if (categoryColors.size <= 1) {
|
|
88
93
|
entries = [];
|
|
89
94
|
} else {
|
|
90
|
-
entries = [...
|
|
95
|
+
entries = [...categoryColors.entries()].map(([label, color]) => ({
|
|
91
96
|
label,
|
|
92
97
|
color,
|
|
93
98
|
shape: 'circle' as const,
|
|
@@ -207,14 +212,17 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
|
|
|
207
212
|
graphSpec.nodeOverrides,
|
|
208
213
|
);
|
|
209
214
|
|
|
210
|
-
// 4. Assign communities
|
|
215
|
+
// 4. Assign communities (for force simulation grouping)
|
|
211
216
|
const clusteringField = graphSpec.layout.clustering?.field;
|
|
212
217
|
const hasCommunities = !!clusteringField;
|
|
213
218
|
assignCommunities(compiledNodes, clusteringField);
|
|
214
219
|
|
|
215
|
-
// 5. Apply community colors
|
|
220
|
+
// 5. Apply community colors only when no explicit nodeColor encoding is set.
|
|
221
|
+
// When the consumer specifies nodeColor (e.g. by nodeType or provider), that
|
|
222
|
+
// encoding should drive both fill colors and legend entries.
|
|
223
|
+
const hasNodeColorEncoding = !!graphSpec.encoding.nodeColor?.field;
|
|
216
224
|
let communityColorMap = new Map<string, string>();
|
|
217
|
-
if (hasCommunities) {
|
|
225
|
+
if (hasCommunities && !hasNodeColorEncoding) {
|
|
218
226
|
communityColorMap = buildCommunityColorMap(compiledNodes, theme);
|
|
219
227
|
applyCommunityColors(compiledNodes, communityColorMap);
|
|
220
228
|
}
|
|
@@ -222,8 +230,15 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
|
|
|
222
230
|
// 6. Resolve edge visuals
|
|
223
231
|
const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
|
|
224
232
|
|
|
225
|
-
// 7. Build legend
|
|
226
|
-
const
|
|
233
|
+
// 7. Build legend (use nodeColor encoding colors when present, community otherwise)
|
|
234
|
+
const useCommunitiesForLegend = hasCommunities && !hasNodeColorEncoding;
|
|
235
|
+
const legend = buildGraphLegend(
|
|
236
|
+
compiledNodes,
|
|
237
|
+
communityColorMap,
|
|
238
|
+
useCommunitiesForLegend,
|
|
239
|
+
theme,
|
|
240
|
+
graphSpec.encoding.nodeColor?.field,
|
|
241
|
+
);
|
|
227
242
|
|
|
228
243
|
// 8. Build tooltips
|
|
229
244
|
const tooltipDescriptors = buildGraphTooltips(compiledNodes);
|
package/src/graphs/encoding.ts
CHANGED
|
@@ -142,6 +142,7 @@ export function resolveNodeVisuals(
|
|
|
142
142
|
if (encoding.nodeColor?.field) {
|
|
143
143
|
const field = encoding.nodeColor.field;
|
|
144
144
|
const fieldType = encoding.nodeColor.type ?? 'nominal';
|
|
145
|
+
const scaleConfig = encoding.nodeColor.scale;
|
|
145
146
|
|
|
146
147
|
if (fieldType === 'quantitative') {
|
|
147
148
|
const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
|
|
@@ -151,9 +152,17 @@ export function resolveNodeVisuals(
|
|
|
151
152
|
// Use first sequential palette
|
|
152
153
|
const seqPalettes = Object.values(theme.colors.sequential);
|
|
153
154
|
const palette = seqPalettes.length > 0 ? seqPalettes[0] : ['#ccc', '#333'];
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
|
|
156
|
+
const domain =
|
|
157
|
+
scaleConfig?.domain && scaleConfig.domain.length === 2
|
|
158
|
+
? (scaleConfig.domain as [number, number])
|
|
159
|
+
: [colorMin, colorMax];
|
|
160
|
+
const range =
|
|
161
|
+
scaleConfig?.range && scaleConfig.range.length >= 2
|
|
162
|
+
? (scaleConfig.range as string[])
|
|
163
|
+
: [palette[0], palette[palette.length - 1]];
|
|
164
|
+
|
|
165
|
+
const colorScale = scaleLinear<string>().domain(domain).range(range);
|
|
157
166
|
|
|
158
167
|
colorFn = (node: GraphNode) => {
|
|
159
168
|
const val = Number(node[field]);
|
|
@@ -161,10 +170,16 @@ export function resolveNodeVisuals(
|
|
|
161
170
|
};
|
|
162
171
|
} else {
|
|
163
172
|
// nominal/ordinal
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
const domain =
|
|
174
|
+
scaleConfig?.domain && Array.isArray(scaleConfig.domain)
|
|
175
|
+
? (scaleConfig.domain as string[])
|
|
176
|
+
: [...new Set(nodes.map((n) => String(n[field] ?? '')))];
|
|
177
|
+
const range =
|
|
178
|
+
scaleConfig?.range && scaleConfig.range.length > 0
|
|
179
|
+
? (scaleConfig.range as string[])
|
|
180
|
+
: theme.colors.categorical;
|
|
181
|
+
|
|
182
|
+
const ordinalScale = scaleOrdinal<string>().domain(domain).range(range);
|
|
168
183
|
|
|
169
184
|
colorFn = (node: GraphNode) => ordinalScale(String(node[field] ?? ''));
|
|
170
185
|
}
|
|
@@ -259,6 +274,7 @@ export function resolveEdgeVisuals(
|
|
|
259
274
|
if (encoding.edgeColor?.field) {
|
|
260
275
|
const field = encoding.edgeColor.field;
|
|
261
276
|
const fieldType = encoding.edgeColor.type ?? 'nominal';
|
|
277
|
+
const scaleConfig = encoding.edgeColor.scale;
|
|
262
278
|
|
|
263
279
|
if (fieldType === 'quantitative') {
|
|
264
280
|
const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
|
|
@@ -267,19 +283,33 @@ export function resolveEdgeVisuals(
|
|
|
267
283
|
|
|
268
284
|
const seqPalettes = Object.values(theme.colors.sequential);
|
|
269
285
|
const palette = seqPalettes.length > 0 ? seqPalettes[0] : ['#ccc', '#333'];
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
286
|
+
|
|
287
|
+
const domain =
|
|
288
|
+
scaleConfig?.domain && scaleConfig.domain.length === 2
|
|
289
|
+
? (scaleConfig.domain as [number, number])
|
|
290
|
+
: [colorMin, colorMax];
|
|
291
|
+
const range =
|
|
292
|
+
scaleConfig?.range && scaleConfig.range.length >= 2
|
|
293
|
+
? (scaleConfig.range as string[])
|
|
294
|
+
: [palette[0], palette[palette.length - 1]];
|
|
295
|
+
|
|
296
|
+
const colorScale = scaleLinear<string>().domain(domain).range(range);
|
|
273
297
|
|
|
274
298
|
edgeColorFn = (edge: GraphEdge) => {
|
|
275
299
|
const val = Number(edge[field]);
|
|
276
300
|
return Number.isFinite(val) ? colorScale(val) : hexWithOpacity(theme.colors.axis, 0.4);
|
|
277
301
|
};
|
|
278
302
|
} else {
|
|
279
|
-
const
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
303
|
+
const domain =
|
|
304
|
+
scaleConfig?.domain && Array.isArray(scaleConfig.domain)
|
|
305
|
+
? (scaleConfig.domain as string[])
|
|
306
|
+
: [...new Set(edges.map((e) => String(e[field] ?? '')))];
|
|
307
|
+
const range =
|
|
308
|
+
scaleConfig?.range && scaleConfig.range.length > 0
|
|
309
|
+
? (scaleConfig.range as string[])
|
|
310
|
+
: theme.colors.categorical;
|
|
311
|
+
|
|
312
|
+
const ordinalScale = scaleOrdinal<string>().domain(domain).range(range);
|
|
283
313
|
|
|
284
314
|
edgeColorFn = (edge: GraphEdge) => ordinalScale(String(edge[field] ?? ''));
|
|
285
315
|
}
|