@opendata-ai/openchart-engine 6.8.0 → 6.10.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 +1 -0
- package/dist/index.js +141 -7
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/charts/bar/__tests__/compute.test.ts +92 -0
- package/src/charts/bar/compute.ts +83 -0
- package/src/charts/column/__tests__/compute.test.ts +66 -0
- package/src/charts/column/compute.ts +89 -1
- package/src/compiler/normalize.ts +1 -0
- package/src/layout/scales.ts +10 -2
- package/src/sankey/compile-sankey.ts +22 -12
- package/src/sankey/types.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opendata-ai/openchart-engine",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.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.10.0",
|
|
49
49
|
"d3-array": "^3.2.0",
|
|
50
50
|
"d3-format": "^3.1.2",
|
|
51
51
|
"d3-interpolate": "^3.0.0",
|
|
@@ -203,6 +203,98 @@ describe('computeBarMarks', () => {
|
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
describe('grouped bars (stack: null)', () => {
|
|
207
|
+
function makeDodgedBarSpec(): NormalizedChartSpec {
|
|
208
|
+
const spec = makeGroupedBarSpec();
|
|
209
|
+
(spec.encoding.x as { stack?: boolean | null }).stack = null;
|
|
210
|
+
return spec;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
it('produces marks for all data rows', () => {
|
|
214
|
+
const spec = makeDodgedBarSpec();
|
|
215
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
216
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
217
|
+
|
|
218
|
+
expect(marks).toHaveLength(6);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('grouped bars within a category have different y positions', () => {
|
|
222
|
+
const spec = makeDodgedBarSpec();
|
|
223
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
224
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
225
|
+
|
|
226
|
+
const q1East = marks.find(
|
|
227
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
228
|
+
)!;
|
|
229
|
+
const q1West = marks.find(
|
|
230
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
231
|
+
)!;
|
|
232
|
+
|
|
233
|
+
expect(q1East.y).not.toBe(q1West.y);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('grouped bars all start from baseline (not cumulative)', () => {
|
|
237
|
+
const spec = makeDodgedBarSpec();
|
|
238
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
239
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
240
|
+
|
|
241
|
+
const q1East = marks.find(
|
|
242
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('East'),
|
|
243
|
+
)!;
|
|
244
|
+
const q1West = marks.find(
|
|
245
|
+
(m) => m.aria.label.includes('Q1') && m.aria.label.includes('West'),
|
|
246
|
+
)!;
|
|
247
|
+
|
|
248
|
+
// Both bars start at the same x position (baseline)
|
|
249
|
+
expect(q1East.x).toBe(q1West.x);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('grouped bars have cornerRadius 2', () => {
|
|
253
|
+
const spec = makeDodgedBarSpec();
|
|
254
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
255
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
256
|
+
|
|
257
|
+
for (const mark of marks) {
|
|
258
|
+
expect(mark.cornerRadius).toBe(2);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('grouped bars do not set stackGroup', () => {
|
|
263
|
+
const spec = makeDodgedBarSpec();
|
|
264
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
265
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
266
|
+
|
|
267
|
+
for (const mark of marks) {
|
|
268
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('sub-band heights are smaller than full bandwidth', () => {
|
|
273
|
+
const spec = makeDodgedBarSpec();
|
|
274
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
275
|
+
const marks = computeBarMarks(spec, scales, chartArea, fullStrategy);
|
|
276
|
+
|
|
277
|
+
// With 2 groups, each sub-bar should be less than the full bandwidth
|
|
278
|
+
const stackedSpec = makeGroupedBarSpec();
|
|
279
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
280
|
+
const stackedMarks = computeBarMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
281
|
+
|
|
282
|
+
expect(marks[0].height).toBeLessThan(stackedMarks[0].height);
|
|
283
|
+
expect(marks[0].height).toBeGreaterThan(0);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
287
|
+
const spec = makeDodgedBarSpec();
|
|
288
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
289
|
+
|
|
290
|
+
// Max individual value is 70 (Q3 West), not 115 (Q3 stacked sum)
|
|
291
|
+
const xScale = scales.x!.scale;
|
|
292
|
+
const domain = xScale.domain() as number[];
|
|
293
|
+
// Domain should not extend to the stacked sum (115)
|
|
294
|
+
expect(domain[1]).toBeLessThanOrEqual(80); // some nice rounding above 70
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
206
298
|
describe('colored (non-stacked) bars', () => {
|
|
207
299
|
it('renders colored bars when each category has one row with color encoding', () => {
|
|
208
300
|
const spec: NormalizedChartSpec = {
|
|
@@ -99,6 +99,22 @@ export function computeBarMarks(
|
|
|
99
99
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
100
100
|
|
|
101
101
|
if (needsStacking) {
|
|
102
|
+
const stackDisabled = xChannel.stack === null || xChannel.stack === false;
|
|
103
|
+
|
|
104
|
+
if (stackDisabled) {
|
|
105
|
+
return computeGroupedBars(
|
|
106
|
+
spec.data,
|
|
107
|
+
xChannel.field,
|
|
108
|
+
yChannel.field,
|
|
109
|
+
colorField,
|
|
110
|
+
xScale,
|
|
111
|
+
yScale,
|
|
112
|
+
bandwidth,
|
|
113
|
+
baseline,
|
|
114
|
+
scales,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
return computeStackedBars(
|
|
103
119
|
spec.data,
|
|
104
120
|
xChannel.field,
|
|
@@ -184,6 +200,73 @@ function computeStackedBars(
|
|
|
184
200
|
return marks;
|
|
185
201
|
}
|
|
186
202
|
|
|
203
|
+
/** Compute grouped (dodged) horizontal bars -- side-by-side within each category band. */
|
|
204
|
+
function computeGroupedBars(
|
|
205
|
+
data: DataRow[],
|
|
206
|
+
valueField: string,
|
|
207
|
+
categoryField: string,
|
|
208
|
+
colorField: string,
|
|
209
|
+
xScale: ScaleLinear<number, number>,
|
|
210
|
+
yScale: ScaleBand<string>,
|
|
211
|
+
bandwidth: number,
|
|
212
|
+
baseline: number,
|
|
213
|
+
scales: ResolvedScales,
|
|
214
|
+
): RectMark[] {
|
|
215
|
+
const marks: RectMark[] = [];
|
|
216
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
217
|
+
|
|
218
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
219
|
+
const groupIndexMap = new Map<string, number>();
|
|
220
|
+
for (const row of data) {
|
|
221
|
+
const key = String(row[colorField] ?? '');
|
|
222
|
+
if (!groupIndexMap.has(key)) {
|
|
223
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const groupCount = groupIndexMap.size;
|
|
227
|
+
if (groupCount === 0) return marks;
|
|
228
|
+
|
|
229
|
+
// Subdivide the band height by group count with a small gap
|
|
230
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
231
|
+
const subBandHeight = Math.max((bandwidth - gap * (groupCount - 1)) / groupCount, MIN_BAR_WIDTH);
|
|
232
|
+
|
|
233
|
+
for (const [category, rows] of categoryGroups) {
|
|
234
|
+
const bandY = yScale(category);
|
|
235
|
+
if (bandY === undefined) continue;
|
|
236
|
+
|
|
237
|
+
for (const row of rows) {
|
|
238
|
+
const groupKey = String(row[colorField] ?? '');
|
|
239
|
+
const value = Number(row[valueField] ?? 0);
|
|
240
|
+
if (!Number.isFinite(value)) continue;
|
|
241
|
+
|
|
242
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
243
|
+
const color = getColor(scales, groupKey);
|
|
244
|
+
const xPos = value >= 0 ? baseline : xScale(value);
|
|
245
|
+
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
246
|
+
const subY = bandY + groupIndex * (subBandHeight + gap);
|
|
247
|
+
|
|
248
|
+
const aria: MarkAria = {
|
|
249
|
+
label: `${category}, ${groupKey}: ${formatBarValue(value)}`,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
marks.push({
|
|
253
|
+
type: 'rect',
|
|
254
|
+
x: xPos,
|
|
255
|
+
y: subY,
|
|
256
|
+
width: barWidth,
|
|
257
|
+
height: subBandHeight,
|
|
258
|
+
fill: color,
|
|
259
|
+
cornerRadius: 2,
|
|
260
|
+
data: row as Record<string, unknown>,
|
|
261
|
+
aria,
|
|
262
|
+
orient: 'horizontal',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return marks;
|
|
268
|
+
}
|
|
269
|
+
|
|
187
270
|
/** Compute colored (non-stacked) horizontal bars. Used when color encoding
|
|
188
271
|
* is present but each category has only one row (e.g., diverging charts). */
|
|
189
272
|
function computeColoredBars(
|
|
@@ -187,6 +187,72 @@ describe('computeColumnMarks', () => {
|
|
|
187
187
|
});
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
describe('grouped columns (stack: null)', () => {
|
|
191
|
+
function makeDodgedColumnSpec(): NormalizedChartSpec {
|
|
192
|
+
const spec = makeGroupedColumnSpec();
|
|
193
|
+
(spec.encoding.y as { stack?: boolean | null }).stack = null;
|
|
194
|
+
return spec;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('produces marks for all data rows', () => {
|
|
198
|
+
const spec = makeDodgedColumnSpec();
|
|
199
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
200
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
201
|
+
|
|
202
|
+
expect(marks).toHaveLength(6);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('grouped columns within a category have different x positions', () => {
|
|
206
|
+
const spec = makeDodgedColumnSpec();
|
|
207
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
208
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
209
|
+
|
|
210
|
+
const janNorth = marks.find(
|
|
211
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('North'),
|
|
212
|
+
)!;
|
|
213
|
+
const janSouth = marks.find(
|
|
214
|
+
(m) => m.aria.label.includes('Jan') && m.aria.label.includes('South'),
|
|
215
|
+
)!;
|
|
216
|
+
|
|
217
|
+
expect(janNorth.x).not.toBe(janSouth.x);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('grouped columns have subdivided widths', () => {
|
|
221
|
+
const spec = makeDodgedColumnSpec();
|
|
222
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
223
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
224
|
+
|
|
225
|
+
// With 2 groups, each sub-column should be narrower than full bandwidth
|
|
226
|
+
const stackedSpec = makeGroupedColumnSpec();
|
|
227
|
+
const stackedScales = computeScales(stackedSpec, chartArea, stackedSpec.data);
|
|
228
|
+
const stackedMarks = computeColumnMarks(stackedSpec, stackedScales, chartArea, fullStrategy);
|
|
229
|
+
|
|
230
|
+
expect(marks[0].width).toBeLessThan(stackedMarks[0].width);
|
|
231
|
+
expect(marks[0].width).toBeGreaterThan(0);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('grouped columns have cornerRadius 2 and no stackGroup', () => {
|
|
235
|
+
const spec = makeDodgedColumnSpec();
|
|
236
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
237
|
+
const marks = computeColumnMarks(spec, scales, chartArea, fullStrategy);
|
|
238
|
+
|
|
239
|
+
for (const mark of marks) {
|
|
240
|
+
expect(mark.cornerRadius).toBe(2);
|
|
241
|
+
expect(mark.stackGroup).toBeUndefined();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('scale domain covers max individual value, not stacked sum', () => {
|
|
246
|
+
const spec = makeDodgedColumnSpec();
|
|
247
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
248
|
+
|
|
249
|
+
// Max individual value is 150 (Mar North), not 280 (Mar stacked sum)
|
|
250
|
+
const yScale = scales.y!.scale;
|
|
251
|
+
const domain = yScale.domain() as number[];
|
|
252
|
+
expect(domain[1]).toBeLessThanOrEqual(170); // some nice rounding above 150
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
190
256
|
describe('negative values', () => {
|
|
191
257
|
it('negative columns extend downward from baseline', () => {
|
|
192
258
|
const spec = makeNegativeColumnSpec();
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Takes a normalized chart spec with resolved scales and produces
|
|
5
5
|
* RectMark[] for rendering vertical columns. When a color encoding
|
|
6
|
-
* is present, columns are stacked (cumulative heights
|
|
6
|
+
* is present, columns are either stacked (cumulative heights) or grouped
|
|
7
|
+
* (side-by-side) based on the `stack` property of the quantitative channel.
|
|
7
8
|
*
|
|
8
9
|
* Shares conceptual logic with bar chart but axes are swapped:
|
|
9
10
|
* x-axis is categorical (band scale), y-axis is quantitative.
|
|
@@ -88,6 +89,22 @@ export function computeColumnMarks(
|
|
|
88
89
|
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
89
90
|
|
|
90
91
|
if (needsStacking) {
|
|
92
|
+
const stackDisabled = yChannel.stack === null || yChannel.stack === false;
|
|
93
|
+
|
|
94
|
+
if (stackDisabled) {
|
|
95
|
+
return computeGroupedColumns(
|
|
96
|
+
spec.data,
|
|
97
|
+
xChannel.field,
|
|
98
|
+
yChannel.field,
|
|
99
|
+
colorField,
|
|
100
|
+
xScale,
|
|
101
|
+
yScale,
|
|
102
|
+
bandwidth,
|
|
103
|
+
baseline,
|
|
104
|
+
scales,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
return computeStackedColumns(
|
|
92
109
|
spec.data,
|
|
93
110
|
xChannel.field,
|
|
@@ -241,6 +258,77 @@ function computeColoredColumns(
|
|
|
241
258
|
return marks;
|
|
242
259
|
}
|
|
243
260
|
|
|
261
|
+
/** Compute grouped (dodged) vertical columns -- side-by-side within each category band. */
|
|
262
|
+
function computeGroupedColumns(
|
|
263
|
+
data: DataRow[],
|
|
264
|
+
categoryField: string,
|
|
265
|
+
valueField: string,
|
|
266
|
+
colorField: string,
|
|
267
|
+
xScale: ScaleBand<string>,
|
|
268
|
+
yScale: ScaleLinear<number, number>,
|
|
269
|
+
bandwidth: number,
|
|
270
|
+
baseline: number,
|
|
271
|
+
scales: ResolvedScales,
|
|
272
|
+
): RectMark[] {
|
|
273
|
+
const marks: RectMark[] = [];
|
|
274
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
275
|
+
|
|
276
|
+
// Build a stable group order from first appearance in data (Map for O(1) lookup)
|
|
277
|
+
const groupIndexMap = new Map<string, number>();
|
|
278
|
+
for (const row of data) {
|
|
279
|
+
const key = String(row[colorField] ?? '');
|
|
280
|
+
if (!groupIndexMap.has(key)) {
|
|
281
|
+
groupIndexMap.set(key, groupIndexMap.size);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const groupCount = groupIndexMap.size;
|
|
285
|
+
if (groupCount === 0) return marks;
|
|
286
|
+
|
|
287
|
+
// Subdivide the band width by group count with a small gap
|
|
288
|
+
const gap = Math.min(1, bandwidth * 0.05);
|
|
289
|
+
const subBandWidth = Math.max(
|
|
290
|
+
(bandwidth - gap * (groupCount - 1)) / groupCount,
|
|
291
|
+
MIN_COLUMN_HEIGHT,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
for (const [category, rows] of categoryGroups) {
|
|
295
|
+
const bandX = xScale(category);
|
|
296
|
+
if (bandX === undefined) continue;
|
|
297
|
+
|
|
298
|
+
for (const row of rows) {
|
|
299
|
+
const groupKey = String(row[colorField] ?? '');
|
|
300
|
+
const value = Number(row[valueField] ?? 0);
|
|
301
|
+
if (!Number.isFinite(value)) continue;
|
|
302
|
+
|
|
303
|
+
const groupIndex = groupIndexMap.get(groupKey) ?? 0;
|
|
304
|
+
const color = getColor(scales, groupKey);
|
|
305
|
+
const yPos = yScale(value);
|
|
306
|
+
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
307
|
+
const y = value >= 0 ? yPos : baseline;
|
|
308
|
+
const subX = bandX + groupIndex * (subBandWidth + gap);
|
|
309
|
+
|
|
310
|
+
const aria: MarkAria = {
|
|
311
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(value)}`,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
marks.push({
|
|
315
|
+
type: 'rect',
|
|
316
|
+
x: subX,
|
|
317
|
+
y,
|
|
318
|
+
width: subBandWidth,
|
|
319
|
+
height: columnHeight,
|
|
320
|
+
fill: color,
|
|
321
|
+
cornerRadius: 2,
|
|
322
|
+
data: row as Record<string, unknown>,
|
|
323
|
+
aria,
|
|
324
|
+
orient: 'vertical',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return marks;
|
|
330
|
+
}
|
|
331
|
+
|
|
244
332
|
/** Compute stacked vertical columns. */
|
|
245
333
|
function computeStackedColumns(
|
|
246
334
|
data: DataRow[],
|
|
@@ -246,6 +246,7 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS
|
|
|
246
246
|
nodeAlign: spec.nodeAlign ?? 'justify',
|
|
247
247
|
iterations: spec.iterations ?? 6,
|
|
248
248
|
linkStyle: spec.linkStyle ?? 'gradient',
|
|
249
|
+
nodeLabelAlign: spec.nodeLabelAlign ?? 'auto',
|
|
249
250
|
chrome: normalizeChrome(spec.chrome),
|
|
250
251
|
legend: spec.legend,
|
|
251
252
|
theme: spec.theme ?? {},
|
package/src/layout/scales.ts
CHANGED
|
@@ -622,7 +622,13 @@ export function computeScales(
|
|
|
622
622
|
// For stacked bars, the x-domain needs the max category sum, not max individual value.
|
|
623
623
|
// Without this, stacked bars would clip past the chart area.
|
|
624
624
|
let xData = data;
|
|
625
|
-
|
|
625
|
+
const xStackDisabled = encoding.x.stack === null || encoding.x.stack === false;
|
|
626
|
+
if (
|
|
627
|
+
spec.markType === 'bar' &&
|
|
628
|
+
encoding.color &&
|
|
629
|
+
encoding.x.type === 'quantitative' &&
|
|
630
|
+
!xStackDisabled
|
|
631
|
+
) {
|
|
626
632
|
const yField = encoding.y?.field;
|
|
627
633
|
const xField = encoding.x.field;
|
|
628
634
|
if (yField) {
|
|
@@ -660,10 +666,12 @@ export function computeScales(
|
|
|
660
666
|
spec.markType === 'bar' &&
|
|
661
667
|
(encoding.x?.type === 'nominal' || encoding.x?.type === 'ordinal') &&
|
|
662
668
|
encoding.y.type === 'quantitative';
|
|
669
|
+
const yStackDisabled = encoding.y.stack === null || encoding.y.stack === false;
|
|
663
670
|
if (
|
|
664
671
|
(isVerticalBar || spec.markType === 'area') &&
|
|
665
672
|
encoding.color &&
|
|
666
|
-
encoding.y.type === 'quantitative'
|
|
673
|
+
encoding.y.type === 'quantitative' &&
|
|
674
|
+
!yStackDisabled
|
|
667
675
|
) {
|
|
668
676
|
const xField = encoding.x?.field;
|
|
669
677
|
const yField = encoding.y.field;
|
|
@@ -131,18 +131,28 @@ function getLinkColors(
|
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
133
|
* Determine label position for a node based on its column depth.
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* Middle columns: label to the right (default).
|
|
134
|
+
* Default ('auto'): leftmost/middle columns label right, rightmost column labels left.
|
|
135
|
+
* 'right': all labels to the right. 'left': all labels to the left.
|
|
137
136
|
*/
|
|
138
137
|
function computeNodeLabel(
|
|
139
138
|
node: ComputedNode,
|
|
140
139
|
maxDepth: number,
|
|
141
140
|
theme: ResolvedTheme,
|
|
142
141
|
nodeWidth: number,
|
|
142
|
+
nodeLabelAlign: 'auto' | 'left' | 'right' = 'auto',
|
|
143
143
|
): SankeyNodeMark['label'] {
|
|
144
144
|
const depth = node.depth ?? 0;
|
|
145
|
-
|
|
145
|
+
|
|
146
|
+
// Determine which side to place the label
|
|
147
|
+
let placeLeft: boolean;
|
|
148
|
+
if (nodeLabelAlign === 'left') {
|
|
149
|
+
placeLeft = true;
|
|
150
|
+
} else if (nodeLabelAlign === 'right') {
|
|
151
|
+
placeLeft = false;
|
|
152
|
+
} else {
|
|
153
|
+
// 'auto': rightmost column goes left, everything else goes right
|
|
154
|
+
placeLeft = depth === maxDepth;
|
|
155
|
+
}
|
|
146
156
|
|
|
147
157
|
const style: TextStyle = {
|
|
148
158
|
fontFamily: theme.fonts.family,
|
|
@@ -158,8 +168,7 @@ function computeNodeLabel(
|
|
|
158
168
|
const y1 = node.y1 ?? 0;
|
|
159
169
|
const midY = (y0 + y1) / 2;
|
|
160
170
|
|
|
161
|
-
if (
|
|
162
|
-
// Label to the left of the node
|
|
171
|
+
if (placeLeft) {
|
|
163
172
|
return {
|
|
164
173
|
text: node.label ?? node.id,
|
|
165
174
|
x: x0 - LABEL_GAP,
|
|
@@ -169,7 +178,6 @@ function computeNodeLabel(
|
|
|
169
178
|
};
|
|
170
179
|
}
|
|
171
180
|
|
|
172
|
-
// Label to the right of the node (leftmost and middle columns)
|
|
173
181
|
return {
|
|
174
182
|
text: node.label ?? node.id,
|
|
175
183
|
x: x1 + LABEL_GAP,
|
|
@@ -311,15 +319,17 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
311
319
|
sankeySpec.iterations,
|
|
312
320
|
);
|
|
313
321
|
|
|
314
|
-
// 6b. Check if any
|
|
315
|
-
|
|
316
|
-
// which can extend past the drawing area boundary.
|
|
322
|
+
// 6b. Check if any right-side node labels overflow the right edge.
|
|
323
|
+
const nodeLabelAlign = sankeySpec.nodeLabelAlign ?? 'auto';
|
|
317
324
|
const maxDepthFirst = nodes.reduce((max, n) => Math.max(max, n.depth ?? 0), 0);
|
|
318
325
|
const rightEdge = area.x + area.width;
|
|
319
326
|
let maxOverflow = 0;
|
|
320
327
|
for (const node of nodes) {
|
|
321
328
|
const depth = node.depth ?? 0;
|
|
322
|
-
|
|
329
|
+
// Skip nodes whose labels go left (they can't overflow the right edge)
|
|
330
|
+
const labelsLeft =
|
|
331
|
+
nodeLabelAlign === 'left' || (nodeLabelAlign === 'auto' && depth === maxDepthFirst);
|
|
332
|
+
if (labelsLeft) continue;
|
|
323
333
|
const labelX = (node.x1 ?? nodeWidth) + LABEL_GAP;
|
|
324
334
|
const labelText = node.label ?? node.id;
|
|
325
335
|
const labelWidth = estimateTextWidth(labelText, labelFontSize, labelFontWeight);
|
|
@@ -375,7 +385,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay
|
|
|
375
385
|
height: (node.y1 ?? 0) - (node.y0 ?? 0),
|
|
376
386
|
fill,
|
|
377
387
|
cornerRadius: NODE_CORNER_RADIUS,
|
|
378
|
-
label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth),
|
|
388
|
+
label: computeNodeLabel(node, maxDepth, theme, sankeySpec.nodeWidth, nodeLabelAlign),
|
|
379
389
|
nodeId: node.id,
|
|
380
390
|
value: node.value ?? 0,
|
|
381
391
|
depth,
|
package/src/sankey/types.ts
CHANGED