@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-engine",
3
- "version": "6.1.5",
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.1.5",
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
- // Collect unique colors from nodes (for nodeColor encoding)
78
- const colorLabels = new Map<string, string>();
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
- if (!colorLabels.has(node.fill)) {
81
- // Use the first node with this color as the label representative
82
- colorLabels.set(node.fill, node.label ?? node.id);
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 colors
87
- if (colorLabels.size <= 1) {
91
+ // Only show legend if there are multiple categories
92
+ if (categoryColors.size <= 1) {
88
93
  entries = [];
89
94
  } else {
90
- entries = [...colorLabels.entries()].map(([color, label]) => ({
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 legend = buildGraphLegend(compiledNodes, communityColorMap, hasCommunities, theme);
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);
@@ -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
- const colorScale = scaleLinear<string>()
155
- .domain([colorMin, colorMax])
156
- .range([palette[0], palette[palette.length - 1]]);
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 uniqueValues = [...new Set(nodes.map((n) => String(n[field] ?? '')))];
165
- const ordinalScale = scaleOrdinal<string>()
166
- .domain(uniqueValues)
167
- .range(theme.colors.categorical);
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
- const colorScale = scaleLinear<string>()
271
- .domain([colorMin, colorMax])
272
- .range([palette[0], palette[palette.length - 1]]);
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 uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? '')))];
280
- const ordinalScale = scaleOrdinal<string>()
281
- .domain(uniqueValues)
282
- .range(theme.colors.categorical);
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
  }