@opendata-ai/openchart-engine 2.0.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/README.md +112 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +159 -52
- 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/annotations/__tests__/compute.test.ts +226 -0
- package/src/annotations/compute.ts +116 -46
- package/src/charts/__tests__/utils.test.ts +195 -0
- package/src/charts/line/__tests__/compute.test.ts +364 -0
- package/src/charts/line/area.ts +9 -3
- package/src/charts/line/compute.ts +5 -2
- package/src/charts/utils.ts +48 -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/src/layout/axes.ts +5 -4
- package/src/layout/scales.ts +8 -3
package/src/charts/utils.ts
CHANGED
|
@@ -80,6 +80,54 @@ export function groupByField(data: DataRow[], field: string | undefined): Map<st
|
|
|
80
80
|
return groups;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Sorting
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sort data rows by a field value in ascending order.
|
|
89
|
+
*
|
|
90
|
+
* Type-aware: numbers compared numerically, Date objects by timestamp,
|
|
91
|
+
* string-encoded numbers parsed and compared numerically, and everything
|
|
92
|
+
* else compared lexicographically (which also handles ISO date strings).
|
|
93
|
+
* Nulls are sorted last. Returns a new array (no mutation).
|
|
94
|
+
*/
|
|
95
|
+
export function sortByField(data: DataRow[], field: string): DataRow[] {
|
|
96
|
+
if (data.length <= 1) return [...data];
|
|
97
|
+
|
|
98
|
+
return [...data].sort((a, b) => {
|
|
99
|
+
const aVal = a[field];
|
|
100
|
+
const bVal = b[field];
|
|
101
|
+
|
|
102
|
+
// Nulls last
|
|
103
|
+
if (aVal == null && bVal == null) return 0;
|
|
104
|
+
if (aVal == null) return 1;
|
|
105
|
+
if (bVal == null) return -1;
|
|
106
|
+
|
|
107
|
+
// Both numbers
|
|
108
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
109
|
+
return aVal - bVal;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Both Dates
|
|
113
|
+
if (aVal instanceof Date && bVal instanceof Date) {
|
|
114
|
+
return aVal.getTime() - bVal.getTime();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// String values: try numeric parse, then lexicographic
|
|
118
|
+
const aStr = String(aVal);
|
|
119
|
+
const bStr = String(bVal);
|
|
120
|
+
|
|
121
|
+
const aNum = Number(aStr);
|
|
122
|
+
const bNum = Number(bStr);
|
|
123
|
+
if (Number.isFinite(aNum) && Number.isFinite(bNum)) {
|
|
124
|
+
return aNum - bNum;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return aStr.localeCompare(bStr);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
83
131
|
// ---------------------------------------------------------------------------
|
|
84
132
|
// Color helpers
|
|
85
133
|
// ---------------------------------------------------------------------------
|
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
|
/**
|
package/src/layout/axes.ts
CHANGED
|
@@ -56,12 +56,13 @@ function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity
|
|
|
56
56
|
function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
|
|
57
57
|
const scale = resolvedScale.scale as D3CategoricalScale;
|
|
58
58
|
const domain: string[] = scale.domain();
|
|
59
|
-
const
|
|
59
|
+
const explicitTickCount = resolvedScale.channel.axis?.tickCount;
|
|
60
|
+
const maxTicks = explicitTickCount ?? TICK_COUNTS[density];
|
|
60
61
|
|
|
61
|
-
// Band scales (bar charts)
|
|
62
|
-
// Only thin
|
|
62
|
+
// Band scales (bar charts) show all category labels by default.
|
|
63
|
+
// Only thin when there's an explicit tickCount override or for point/ordinal scales.
|
|
63
64
|
let selectedValues = domain;
|
|
64
|
-
if (resolvedScale.type !== 'band' && domain.length > maxTicks) {
|
|
65
|
+
if ((resolvedScale.type !== 'band' || explicitTickCount) && domain.length > maxTicks) {
|
|
65
66
|
const step = Math.ceil(domain.length / maxTicks);
|
|
66
67
|
selectedValues = domain.filter((_: string, i: number) => i % step === 0);
|
|
67
68
|
}
|
package/src/layout/scales.ts
CHANGED
|
@@ -367,10 +367,15 @@ export function computeScales(
|
|
|
367
367
|
}
|
|
368
368
|
|
|
369
369
|
if (encoding.y) {
|
|
370
|
-
// For stacked columns, the y-domain needs the max category
|
|
371
|
-
// Without this, stacked
|
|
370
|
+
// For stacked columns and stacked areas, the y-domain needs the max category
|
|
371
|
+
// sum, not the max individual value. Without this, stacked marks would clip
|
|
372
|
+
// above the chart area.
|
|
372
373
|
let yData = data;
|
|
373
|
-
if (
|
|
374
|
+
if (
|
|
375
|
+
(spec.type === 'column' || spec.type === 'area') &&
|
|
376
|
+
encoding.color &&
|
|
377
|
+
encoding.y.type === 'quantitative'
|
|
378
|
+
) {
|
|
374
379
|
const xField = encoding.x?.field;
|
|
375
380
|
const yField = encoding.y.field;
|
|
376
381
|
if (xField) {
|