@opendata-ai/openchart-engine 2.1.0 → 2.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.d.ts +10 -1
- package/dist/index.js +58 -13
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__test-fixtures__/specs.ts +3 -0
- package/src/__tests__/compile-chart.test.ts +123 -0
- package/src/compile.ts +33 -4
- package/src/compiler/normalize.ts +2 -0
- package/src/compiler/types.ts +4 -0
- package/src/graphs/__tests__/encoding.test.ts +101 -0
- package/src/graphs/compile-graph.ts +6 -1
- package/src/graphs/encoding.ts +30 -6
- package/src/graphs/types.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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": "2.
|
|
48
|
+
"@opendata-ai/openchart-core": "2.2.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -66,6 +66,7 @@ export function makeLineSpec(): NormalizedChartSpec {
|
|
|
66
66
|
theme: {},
|
|
67
67
|
darkMode: 'off',
|
|
68
68
|
labels: { density: 'auto', format: '' },
|
|
69
|
+
hiddenSeries: [],
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -92,6 +93,7 @@ export function makeBarSpec(): NormalizedChartSpec {
|
|
|
92
93
|
theme: {},
|
|
93
94
|
darkMode: 'off',
|
|
94
95
|
labels: { density: 'auto', format: '' },
|
|
96
|
+
hiddenSeries: [],
|
|
95
97
|
};
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -120,5 +122,6 @@ export function makeScatterSpec(): NormalizedChartSpec {
|
|
|
120
122
|
theme: {},
|
|
121
123
|
darkMode: 'off',
|
|
122
124
|
labels: { density: 'auto', format: '' },
|
|
125
|
+
hiddenSeries: [],
|
|
123
126
|
};
|
|
124
127
|
}
|
|
@@ -260,6 +260,129 @@ describe('compileChart', () => {
|
|
|
260
260
|
),
|
|
261
261
|
).toThrow('compileTable');
|
|
262
262
|
});
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// hiddenSeries
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
it('hiddenSeries filters out data for hidden series from marks', () => {
|
|
269
|
+
const spec = {
|
|
270
|
+
...lineSpec,
|
|
271
|
+
hiddenSeries: ['UK'],
|
|
272
|
+
};
|
|
273
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
274
|
+
|
|
275
|
+
// With UK hidden, only US marks should be present.
|
|
276
|
+
// Line marks carry a series property.
|
|
277
|
+
const lineMarks = layout.marks.filter((m) => m.type === 'line');
|
|
278
|
+
for (const mark of lineMarks) {
|
|
279
|
+
if (mark.type === 'line') {
|
|
280
|
+
expect(mark.series).not.toBe('UK');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Legend should still have entries for both series (hidden ones are dimmed, not removed)
|
|
285
|
+
expect(layout.legend.entries.length).toBe(2);
|
|
286
|
+
expect(layout.legend.entries.some((e) => e.label === 'US')).toBe(true);
|
|
287
|
+
expect(layout.legend.entries.some((e) => e.label === 'UK')).toBe(true);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('hiddenSeries with all series hidden produces no marks', () => {
|
|
291
|
+
const spec = {
|
|
292
|
+
...lineSpec,
|
|
293
|
+
hiddenSeries: ['US', 'UK'],
|
|
294
|
+
};
|
|
295
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
296
|
+
// No data left means no marks
|
|
297
|
+
expect(layout.marks.length).toBe(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('hiddenSeries with empty array behaves normally', () => {
|
|
301
|
+
const spec = {
|
|
302
|
+
...lineSpec,
|
|
303
|
+
hiddenSeries: [],
|
|
304
|
+
};
|
|
305
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
306
|
+
expect(layout.marks.length).toBeGreaterThan(0);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// scale.clip
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
it('scale.clip filters data rows outside the y-axis domain', () => {
|
|
314
|
+
const spec = {
|
|
315
|
+
type: 'scatter' as const,
|
|
316
|
+
data: [
|
|
317
|
+
{ x: 1, y: 5 },
|
|
318
|
+
{ x: 2, y: 15 },
|
|
319
|
+
{ x: 3, y: 25 },
|
|
320
|
+
{ x: 4, y: 35 },
|
|
321
|
+
],
|
|
322
|
+
encoding: {
|
|
323
|
+
x: { field: 'x', type: 'quantitative' as const },
|
|
324
|
+
y: {
|
|
325
|
+
field: 'y',
|
|
326
|
+
type: 'quantitative' as const,
|
|
327
|
+
scale: { domain: [10, 30] as [number, number], clip: true },
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
332
|
+
|
|
333
|
+
// Only y=15 and y=25 should remain (y=5 and y=35 are outside [10,30])
|
|
334
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
335
|
+
expect(pointMarks.length).toBe(2);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('scale.clip filters data rows outside the x-axis domain', () => {
|
|
339
|
+
const spec = {
|
|
340
|
+
type: 'scatter' as const,
|
|
341
|
+
data: [
|
|
342
|
+
{ x: 1, y: 10 },
|
|
343
|
+
{ x: 5, y: 20 },
|
|
344
|
+
{ x: 10, y: 30 },
|
|
345
|
+
{ x: 15, y: 40 },
|
|
346
|
+
],
|
|
347
|
+
encoding: {
|
|
348
|
+
x: {
|
|
349
|
+
field: 'x',
|
|
350
|
+
type: 'quantitative' as const,
|
|
351
|
+
scale: { domain: [3, 12] as [number, number], clip: true },
|
|
352
|
+
},
|
|
353
|
+
y: { field: 'y', type: 'quantitative' as const },
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
357
|
+
|
|
358
|
+
// Only x=5 and x=10 should remain
|
|
359
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
360
|
+
expect(pointMarks.length).toBe(2);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('scale.clip=false does not filter data even with domain set', () => {
|
|
364
|
+
const spec = {
|
|
365
|
+
type: 'scatter' as const,
|
|
366
|
+
data: [
|
|
367
|
+
{ x: 1, y: 5 },
|
|
368
|
+
{ x: 2, y: 15 },
|
|
369
|
+
{ x: 3, y: 25 },
|
|
370
|
+
],
|
|
371
|
+
encoding: {
|
|
372
|
+
x: { field: 'x', type: 'quantitative' as const },
|
|
373
|
+
y: {
|
|
374
|
+
field: 'y',
|
|
375
|
+
type: 'quantitative' as const,
|
|
376
|
+
scale: { domain: [10, 20] as [number, number], clip: false },
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
};
|
|
380
|
+
const layout = compileChart(spec, { width: 600, height: 400 });
|
|
381
|
+
|
|
382
|
+
// All 3 points should still be present (clip is false)
|
|
383
|
+
const pointMarks = layout.marks.filter((m) => m.type === 'point');
|
|
384
|
+
expect(pointMarks.length).toBe(3);
|
|
385
|
+
});
|
|
263
386
|
});
|
|
264
387
|
|
|
265
388
|
describe('compileTable', () => {
|
package/src/compile.ts
CHANGED
|
@@ -209,8 +209,37 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
209
209
|
}
|
|
210
210
|
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
|
|
211
211
|
|
|
212
|
+
// Apply data filtering after legend (so legend retains all series), but before
|
|
213
|
+
// scale computation (so hidden/clipped data doesn't affect domains or marks).
|
|
214
|
+
let renderData = chartSpec.data;
|
|
215
|
+
|
|
216
|
+
// Filter hidden series: removed from rendering but kept in legend (dimmed in the adapter)
|
|
217
|
+
if (chartSpec.hiddenSeries.length > 0 && chartSpec.encoding.color) {
|
|
218
|
+
const colorField = chartSpec.encoding.color.field;
|
|
219
|
+
const hiddenSet = new Set(chartSpec.hiddenSeries);
|
|
220
|
+
renderData = renderData.filter((row) => !hiddenSet.has(String(row[colorField])));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Filter clipped scale domains: when scale.clip is true, exclude rows outside the domain
|
|
224
|
+
for (const channel of ['x', 'y'] as const) {
|
|
225
|
+
const enc = chartSpec.encoding[channel];
|
|
226
|
+
if (!enc?.scale?.clip || !enc.scale.domain) continue;
|
|
227
|
+
const domain = enc.scale.domain;
|
|
228
|
+
const field = enc.field;
|
|
229
|
+
if (Array.isArray(domain) && domain.length === 2 && typeof domain[0] === 'number') {
|
|
230
|
+
const [lo, hi] = domain as [number, number];
|
|
231
|
+
renderData = renderData.filter((row) => {
|
|
232
|
+
const v = Number(row[field]);
|
|
233
|
+
return Number.isFinite(v) && v >= lo && v <= hi;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Build a filtered spec for scales and marks, keeping all other properties intact
|
|
239
|
+
const renderSpec = renderData !== chartSpec.data ? { ...chartSpec, data: renderData } : chartSpec;
|
|
240
|
+
|
|
212
241
|
// Compute scales
|
|
213
|
-
const scales = computeScales(
|
|
242
|
+
const scales = computeScales(renderSpec, chartArea, renderSpec.data);
|
|
214
243
|
|
|
215
244
|
// Update color scale to use theme palette
|
|
216
245
|
if (scales.color) {
|
|
@@ -244,9 +273,9 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
244
273
|
computeGridlines(axes, chartArea);
|
|
245
274
|
}
|
|
246
275
|
|
|
247
|
-
// Get chart renderer and compute marks
|
|
248
|
-
const renderer = getChartRenderer(
|
|
249
|
-
const marks: Mark[] = renderer ? renderer(
|
|
276
|
+
// Get chart renderer and compute marks (using filtered data)
|
|
277
|
+
const renderer = getChartRenderer(renderSpec.type);
|
|
278
|
+
const marks: Mark[] = renderer ? renderer(renderSpec, scales, chartArea, strategy, theme) : [];
|
|
250
279
|
|
|
251
280
|
// Compute annotations from spec, passing legend + mark bounds as obstacles for collision avoidance
|
|
252
281
|
const obstacles: Rect[] = [];
|
|
@@ -196,6 +196,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar
|
|
|
196
196
|
responsive: spec.responsive ?? true,
|
|
197
197
|
theme: spec.theme ?? {},
|
|
198
198
|
darkMode: spec.darkMode ?? 'off',
|
|
199
|
+
hiddenSeries: spec.hiddenSeries ?? [],
|
|
199
200
|
};
|
|
200
201
|
}
|
|
201
202
|
|
|
@@ -236,6 +237,7 @@ function normalizeGraphSpec(spec: GraphSpec, _warnings: string[]): NormalizedGra
|
|
|
236
237
|
edges: spec.edges,
|
|
237
238
|
encoding: spec.encoding ?? {},
|
|
238
239
|
layout,
|
|
240
|
+
nodeOverrides: spec.nodeOverrides,
|
|
239
241
|
chrome: normalizeChrome(spec.chrome),
|
|
240
242
|
annotations: normalizeAnnotations(spec.annotations),
|
|
241
243
|
theme: spec.theme ?? {},
|
package/src/compiler/types.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
GraphSpec,
|
|
23
23
|
LabelConfig,
|
|
24
24
|
LegendConfig,
|
|
25
|
+
NodeOverride,
|
|
25
26
|
ScaleConfig,
|
|
26
27
|
ThemeConfig,
|
|
27
28
|
} from '@opendata-ai/openchart-core';
|
|
@@ -70,6 +71,8 @@ export interface NormalizedChartSpec {
|
|
|
70
71
|
responsive: boolean;
|
|
71
72
|
theme: ThemeConfig;
|
|
72
73
|
darkMode: DarkMode;
|
|
74
|
+
/** Series names to hide from rendering. */
|
|
75
|
+
hiddenSeries: string[];
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
/** A TableSpec with all optional fields filled with sensible defaults. */
|
|
@@ -95,6 +98,7 @@ export interface NormalizedGraphSpec {
|
|
|
95
98
|
edges: GraphSpec['edges'];
|
|
96
99
|
encoding: GraphEncoding;
|
|
97
100
|
layout: GraphLayoutConfig;
|
|
101
|
+
nodeOverrides?: Record<string, NodeOverride>;
|
|
98
102
|
chrome: NormalizedChrome;
|
|
99
103
|
annotations: Annotation[];
|
|
100
104
|
theme: ThemeConfig;
|
|
@@ -215,6 +215,60 @@ describe('resolveNodeVisuals', () => {
|
|
|
215
215
|
});
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
// nodeOverrides tests
|
|
220
|
+
// ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
describe('nodeOverrides', () => {
|
|
223
|
+
it('overrides fill color for a specific node', () => {
|
|
224
|
+
const overrides = { a: { fill: '#ff0000' } };
|
|
225
|
+
const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
|
|
226
|
+
|
|
227
|
+
const nodeA = nodes.find((n) => n.id === 'a')!;
|
|
228
|
+
expect(nodeA.fill).toBe('#ff0000');
|
|
229
|
+
|
|
230
|
+
// Other nodes should not be affected
|
|
231
|
+
const nodeB = nodes.find((n) => n.id === 'b')!;
|
|
232
|
+
expect(nodeB.fill).not.toBe('#ff0000');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('overrides radius for a specific node', () => {
|
|
236
|
+
const overrides = { b: { radius: 15 } };
|
|
237
|
+
const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
|
|
238
|
+
|
|
239
|
+
const nodeB = nodes.find((n) => n.id === 'b')!;
|
|
240
|
+
expect(nodeB.radius).toBe(15);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('overrides strokeWidth and stroke', () => {
|
|
244
|
+
const overrides = { c: { strokeWidth: 3, stroke: '#00ff00' } };
|
|
245
|
+
const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
|
|
246
|
+
|
|
247
|
+
const nodeC = nodes.find((n) => n.id === 'c')!;
|
|
248
|
+
expect(nodeC.strokeWidth).toBe(3);
|
|
249
|
+
expect(nodeC.stroke).toBe('#00ff00');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('alwaysShowLabel sets labelPriority to Infinity', () => {
|
|
253
|
+
const overrides = { a: { alwaysShowLabel: true } };
|
|
254
|
+
const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
|
|
255
|
+
|
|
256
|
+
const nodeA = nodes.find((n) => n.id === 'a')!;
|
|
257
|
+
expect(nodeA.labelPriority).toBe(Infinity);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('does not affect nodes without overrides', () => {
|
|
261
|
+
const overrides = { a: { fill: '#ff0000', radius: 25 } };
|
|
262
|
+
const nodes = resolveNodeVisuals(basicNodes, {}, basicEdges, theme, overrides);
|
|
263
|
+
|
|
264
|
+
const nodeB = nodes.find((n) => n.id === 'b')!;
|
|
265
|
+
const nodeC = nodes.find((n) => n.id === 'c')!;
|
|
266
|
+
// Default radius
|
|
267
|
+
expect(nodeB.radius).toBe(5);
|
|
268
|
+
expect(nodeC.radius).toBe(5);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
218
272
|
// ---------------------------------------------------------------------------
|
|
219
273
|
// resolveEdgeVisuals tests
|
|
220
274
|
// ---------------------------------------------------------------------------
|
|
@@ -280,6 +334,53 @@ describe('resolveEdgeVisuals', () => {
|
|
|
280
334
|
});
|
|
281
335
|
});
|
|
282
336
|
|
|
337
|
+
describe('edge style mapping', () => {
|
|
338
|
+
it('maps field values to solid/dashed/dotted via ordinal mapping', () => {
|
|
339
|
+
const styledEdges: GraphEdge[] = [
|
|
340
|
+
{ source: 'a', target: 'b', kind: 'friend' },
|
|
341
|
+
{ source: 'b', target: 'c', kind: 'colleague' },
|
|
342
|
+
{ source: 'a', target: 'c', kind: 'family' },
|
|
343
|
+
];
|
|
344
|
+
const encoding: GraphEncoding = {
|
|
345
|
+
edgeStyle: { field: 'kind' },
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const edges = resolveEdgeVisuals(styledEdges, encoding, theme);
|
|
349
|
+
|
|
350
|
+
// Three unique values should map to solid, dashed, dotted
|
|
351
|
+
const styles = edges.map((e) => e.style);
|
|
352
|
+
expect(styles).toContain('solid');
|
|
353
|
+
expect(styles).toContain('dashed');
|
|
354
|
+
expect(styles).toContain('dotted');
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('wraps around when more unique values than style options', () => {
|
|
358
|
+
const styledEdges: GraphEdge[] = [
|
|
359
|
+
{ source: 'a', target: 'b', kind: 'one' },
|
|
360
|
+
{ source: 'b', target: 'c', kind: 'two' },
|
|
361
|
+
{ source: 'a', target: 'c', kind: 'three' },
|
|
362
|
+
{ source: 'a', target: 'b', kind: 'four' },
|
|
363
|
+
];
|
|
364
|
+
const encoding: GraphEncoding = {
|
|
365
|
+
edgeStyle: { field: 'kind' },
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const edges = resolveEdgeVisuals(styledEdges, encoding, theme);
|
|
369
|
+
|
|
370
|
+
// 4th unique value wraps back to 'solid'
|
|
371
|
+
const fourthEdge = edges.find((e) => e.data.kind === 'four')!;
|
|
372
|
+
expect(fourthEdge.style).toBe('solid');
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('defaults to solid when no edgeStyle encoding', () => {
|
|
376
|
+
const edges = resolveEdgeVisuals(basicEdges, {}, theme);
|
|
377
|
+
|
|
378
|
+
for (const edge of edges) {
|
|
379
|
+
expect(edge.style).toBe('solid');
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
283
384
|
describe('data preservation', () => {
|
|
284
385
|
it('original edge data is preserved', () => {
|
|
285
386
|
const edges = resolveEdgeVisuals(basicEdges, {}, theme);
|
|
@@ -204,6 +204,7 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
|
|
|
204
204
|
graphSpec.encoding,
|
|
205
205
|
graphSpec.edges,
|
|
206
206
|
theme,
|
|
207
|
+
graphSpec.nodeOverrides,
|
|
207
208
|
);
|
|
208
209
|
|
|
209
210
|
// 4. Assign communities
|
|
@@ -243,6 +244,7 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
|
|
|
243
244
|
};
|
|
244
245
|
|
|
245
246
|
// 10. Build simulation config
|
|
247
|
+
const collisionPadding = graphSpec.layout.collisionPadding ?? 2;
|
|
246
248
|
const maxRadius =
|
|
247
249
|
compiledNodes.length > 0
|
|
248
250
|
? Math.max(...compiledNodes.map((n) => n.radius))
|
|
@@ -253,7 +255,10 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi
|
|
|
253
255
|
clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
|
|
254
256
|
alphaDecay: 0.0228,
|
|
255
257
|
velocityDecay: 0.4,
|
|
256
|
-
collisionRadius: maxRadius +
|
|
258
|
+
collisionRadius: maxRadius + collisionPadding,
|
|
259
|
+
collisionPadding,
|
|
260
|
+
linkStrength: graphSpec.layout.linkStrength,
|
|
261
|
+
centerForce: graphSpec.layout.centerForce,
|
|
257
262
|
};
|
|
258
263
|
|
|
259
264
|
// 11. Build chrome
|
package/src/graphs/encoding.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
GraphEdge,
|
|
12
12
|
GraphEncoding,
|
|
13
13
|
GraphNode,
|
|
14
|
+
NodeOverride,
|
|
14
15
|
ResolvedTheme,
|
|
15
16
|
} from '@opendata-ai/openchart-core';
|
|
16
17
|
import { max, min } from 'd3-array';
|
|
@@ -119,6 +120,7 @@ export function resolveNodeVisuals(
|
|
|
119
120
|
encoding: GraphEncoding,
|
|
120
121
|
edges: GraphEdge[],
|
|
121
122
|
theme: ResolvedTheme,
|
|
123
|
+
nodeOverrides?: Record<string, NodeOverride>,
|
|
122
124
|
): CompiledGraphNode[] {
|
|
123
125
|
const degrees = computeDegrees(nodes, edges);
|
|
124
126
|
const maxDegree = Math.max(1, ...degrees.values());
|
|
@@ -203,14 +205,22 @@ export function resolveNodeVisuals(
|
|
|
203
205
|
const { id: _id, ...rest } = node;
|
|
204
206
|
const data: Record<string, unknown> = { id: node.id, ...rest };
|
|
205
207
|
|
|
208
|
+
// Apply per-node overrides if present
|
|
209
|
+
const override = nodeOverrides?.[node.id];
|
|
210
|
+
const finalFill = override?.fill ?? fill;
|
|
211
|
+
const finalRadius = override?.radius ?? radius;
|
|
212
|
+
const finalStrokeWidth = override?.strokeWidth ?? DEFAULT_STROKE_WIDTH;
|
|
213
|
+
const finalStroke = override?.stroke ?? stroke;
|
|
214
|
+
const finalLabelPriority = override?.alwaysShowLabel ? Infinity : labelPriority;
|
|
215
|
+
|
|
206
216
|
return {
|
|
207
217
|
id: node.id,
|
|
208
|
-
radius,
|
|
209
|
-
fill,
|
|
210
|
-
stroke,
|
|
211
|
-
strokeWidth:
|
|
218
|
+
radius: finalRadius,
|
|
219
|
+
fill: finalFill,
|
|
220
|
+
stroke: finalStroke,
|
|
221
|
+
strokeWidth: finalStrokeWidth,
|
|
212
222
|
label,
|
|
213
|
-
labelPriority,
|
|
223
|
+
labelPriority: finalLabelPriority,
|
|
214
224
|
community: undefined,
|
|
215
225
|
data,
|
|
216
226
|
};
|
|
@@ -277,6 +287,19 @@ export function resolveEdgeVisuals(
|
|
|
277
287
|
|
|
278
288
|
const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
|
|
279
289
|
|
|
290
|
+
// Edge style mapping (ordinal: map unique field values to solid/dashed/dotted)
|
|
291
|
+
const EDGE_STYLES: Array<'solid' | 'dashed' | 'dotted'> = ['solid', 'dashed', 'dotted'];
|
|
292
|
+
let styleFn: ((edge: GraphEdge) => 'solid' | 'dashed' | 'dotted') | undefined;
|
|
293
|
+
if (encoding.edgeStyle?.field) {
|
|
294
|
+
const field = encoding.edgeStyle.field;
|
|
295
|
+
const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? '')))];
|
|
296
|
+
const styleMap = new Map<string, 'solid' | 'dashed' | 'dotted'>();
|
|
297
|
+
for (let i = 0; i < uniqueValues.length; i++) {
|
|
298
|
+
styleMap.set(uniqueValues[i], EDGE_STYLES[i % EDGE_STYLES.length]);
|
|
299
|
+
}
|
|
300
|
+
styleFn = (edge: GraphEdge) => styleMap.get(String(edge[field] ?? '')) ?? 'solid';
|
|
301
|
+
}
|
|
302
|
+
|
|
280
303
|
return edges.map((edge) => {
|
|
281
304
|
const { source, target, ...rest } = edge;
|
|
282
305
|
|
|
@@ -289,13 +312,14 @@ export function resolveEdgeVisuals(
|
|
|
289
312
|
}
|
|
290
313
|
|
|
291
314
|
const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
|
|
315
|
+
const style = styleFn ? styleFn(edge) : ('solid' as const);
|
|
292
316
|
|
|
293
317
|
return {
|
|
294
318
|
source,
|
|
295
319
|
target,
|
|
296
320
|
stroke,
|
|
297
321
|
strokeWidth,
|
|
298
|
-
style
|
|
322
|
+
style,
|
|
299
323
|
data: { source, target, ...rest } as Record<string, unknown>,
|
|
300
324
|
};
|
|
301
325
|
});
|
package/src/graphs/types.ts
CHANGED
|
@@ -67,6 +67,12 @@ export interface SimulationConfig {
|
|
|
67
67
|
velocityDecay: number;
|
|
68
68
|
/** Collision radius: max node radius + padding. */
|
|
69
69
|
collisionRadius: number;
|
|
70
|
+
/** Extra px added to node radius for collision (default 2). */
|
|
71
|
+
collisionPadding?: number;
|
|
72
|
+
/** Link force strength override. */
|
|
73
|
+
linkStrength?: number;
|
|
74
|
+
/** Whether to apply center force (default true). */
|
|
75
|
+
centerForce?: boolean;
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
/**
|