@opendata-ai/openchart-engine 6.2.0 → 6.3.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 +215 -22
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/legend.test.ts +102 -0
- package/src/annotations/__tests__/compute.test.ts +107 -0
- package/src/annotations/compute.ts +84 -2
- package/src/charts/bar/__tests__/labels.test.ts +112 -0
- package/src/charts/bar/labels.ts +77 -4
- package/src/charts/line/labels.ts +6 -2
- package/src/charts/scatter/__tests__/compute.test.ts +121 -0
- package/src/charts/scatter/compute.ts +63 -12
- package/src/compile.ts +2 -0
- package/src/compiler/__tests__/validate.test.ts +34 -0
- package/src/compiler/validate.ts +34 -0
- package/src/layout/axes.ts +2 -1
- package/src/layout/dimensions.ts +19 -1
- package/src/layout/scales.ts +4 -1
- package/src/legend/compute.ts +22 -2
- package/src/tooltips/__tests__/compute.test.ts +61 -0
- package/src/tooltips/compute.ts +14 -0
|
@@ -248,6 +248,127 @@ describe('computeScatterMarks', () => {
|
|
|
248
248
|
expect(marks).toHaveLength(2);
|
|
249
249
|
});
|
|
250
250
|
});
|
|
251
|
+
|
|
252
|
+
describe('nominal y axis', () => {
|
|
253
|
+
function makeNominalYSpec(): NormalizedChartSpec {
|
|
254
|
+
return {
|
|
255
|
+
markType: 'point',
|
|
256
|
+
markDef: { type: 'point' },
|
|
257
|
+
data: [
|
|
258
|
+
{ value: 10, category: 'A' },
|
|
259
|
+
{ value: 30, category: 'B' },
|
|
260
|
+
{ value: 50, category: 'C' },
|
|
261
|
+
],
|
|
262
|
+
encoding: {
|
|
263
|
+
x: { field: 'value', type: 'quantitative' },
|
|
264
|
+
y: { field: 'category', type: 'nominal' },
|
|
265
|
+
},
|
|
266
|
+
chrome: {},
|
|
267
|
+
annotations: [],
|
|
268
|
+
responsive: true,
|
|
269
|
+
theme: {},
|
|
270
|
+
darkMode: 'off',
|
|
271
|
+
labels: { density: 'auto', format: '' },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
it('produces one PointMark per data row', () => {
|
|
276
|
+
const spec = makeNominalYSpec();
|
|
277
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
278
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
279
|
+
|
|
280
|
+
expect(marks).toHaveLength(3);
|
|
281
|
+
expect(marks.every((m) => m.type === 'point')).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('positions are within chart area bounds', () => {
|
|
285
|
+
const spec = makeNominalYSpec();
|
|
286
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
287
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
288
|
+
|
|
289
|
+
for (const mark of marks) {
|
|
290
|
+
expect(mark.cx).toBeGreaterThanOrEqual(chartArea.x);
|
|
291
|
+
expect(mark.cx).toBeLessThanOrEqual(chartArea.x + chartArea.width);
|
|
292
|
+
expect(mark.cy).toBeGreaterThanOrEqual(chartArea.y);
|
|
293
|
+
expect(mark.cy).toBeLessThanOrEqual(chartArea.y + chartArea.height);
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('different categories get different y positions', () => {
|
|
298
|
+
const spec = makeNominalYSpec();
|
|
299
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
300
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
301
|
+
|
|
302
|
+
const yPositions = marks.map((m) => m.cy);
|
|
303
|
+
const uniqueYs = new Set(yPositions);
|
|
304
|
+
expect(uniqueYs.size).toBe(3);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe('temporal x axis', () => {
|
|
309
|
+
it('produces marks for temporal x with quantitative y', () => {
|
|
310
|
+
const spec: NormalizedChartSpec = {
|
|
311
|
+
markType: 'point',
|
|
312
|
+
markDef: { type: 'point' },
|
|
313
|
+
data: [
|
|
314
|
+
{ date: '2020-01-01', value: 10 },
|
|
315
|
+
{ date: '2021-01-01', value: 30 },
|
|
316
|
+
{ date: '2022-01-01', value: 20 },
|
|
317
|
+
],
|
|
318
|
+
encoding: {
|
|
319
|
+
x: { field: 'date', type: 'temporal' },
|
|
320
|
+
y: { field: 'value', type: 'quantitative' },
|
|
321
|
+
},
|
|
322
|
+
chrome: {},
|
|
323
|
+
annotations: [],
|
|
324
|
+
responsive: true,
|
|
325
|
+
theme: {},
|
|
326
|
+
darkMode: 'off',
|
|
327
|
+
labels: { density: 'auto', format: '' },
|
|
328
|
+
};
|
|
329
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
330
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
331
|
+
|
|
332
|
+
expect(marks).toHaveLength(3);
|
|
333
|
+
for (const mark of marks) {
|
|
334
|
+
expect(mark.cx).toBeGreaterThanOrEqual(chartArea.x);
|
|
335
|
+
expect(mark.cx).toBeLessThanOrEqual(chartArea.x + chartArea.width);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
describe('nominal x axis', () => {
|
|
341
|
+
it('produces marks for nominal x with quantitative y', () => {
|
|
342
|
+
const spec: NormalizedChartSpec = {
|
|
343
|
+
markType: 'point',
|
|
344
|
+
markDef: { type: 'point' },
|
|
345
|
+
data: [
|
|
346
|
+
{ category: 'X', value: 10 },
|
|
347
|
+
{ category: 'Y', value: 30 },
|
|
348
|
+
{ category: 'Z', value: 20 },
|
|
349
|
+
],
|
|
350
|
+
encoding: {
|
|
351
|
+
x: { field: 'category', type: 'nominal' },
|
|
352
|
+
y: { field: 'value', type: 'quantitative' },
|
|
353
|
+
},
|
|
354
|
+
chrome: {},
|
|
355
|
+
annotations: [],
|
|
356
|
+
responsive: true,
|
|
357
|
+
theme: {},
|
|
358
|
+
darkMode: 'off',
|
|
359
|
+
labels: { density: 'auto', format: '' },
|
|
360
|
+
};
|
|
361
|
+
const scales = computeScales(spec, chartArea, spec.data);
|
|
362
|
+
const marks = computeScatterMarks(spec, scales, chartArea, fullStrategy);
|
|
363
|
+
|
|
364
|
+
expect(marks).toHaveLength(3);
|
|
365
|
+
|
|
366
|
+
// Different categories should produce different x positions
|
|
367
|
+
const xPositions = marks.map((m) => m.cx);
|
|
368
|
+
const uniqueXs = new Set(xPositions);
|
|
369
|
+
expect(uniqueXs.size).toBe(3);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
251
372
|
});
|
|
252
373
|
|
|
253
374
|
// ---------------------------------------------------------------------------
|
|
@@ -2,20 +2,21 @@
|
|
|
2
2
|
* Scatter / bubble chart mark computation.
|
|
3
3
|
*
|
|
4
4
|
* Takes a normalized chart spec with resolved scales and produces
|
|
5
|
-
* PointMark[] for rendering scatter plots.
|
|
5
|
+
* PointMark[] for rendering scatter plots. Axes can be any field type.
|
|
6
6
|
* Optional size encoding produces area-proportional bubbles via sqrt
|
|
7
7
|
* scaling, and color encoding groups points by category.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type {
|
|
11
11
|
Encoding,
|
|
12
|
+
FieldType,
|
|
12
13
|
LayoutStrategy,
|
|
13
14
|
MarkAria,
|
|
14
15
|
PointMark,
|
|
15
16
|
Rect,
|
|
16
17
|
} from '@opendata-ai/openchart-core';
|
|
17
18
|
import { max, min } from 'd3-array';
|
|
18
|
-
import type { ScaleLinear } from 'd3-scale';
|
|
19
|
+
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
|
|
19
20
|
import { scaleSqrt } from 'd3-scale';
|
|
20
21
|
|
|
21
22
|
import type { NormalizedChartSpec } from '../../compiler/types';
|
|
@@ -30,6 +31,45 @@ const DEFAULT_POINT_RADIUS = 5;
|
|
|
30
31
|
const MIN_BUBBLE_RADIUS = 3;
|
|
31
32
|
const MAX_BUBBLE_RADIUS = 30;
|
|
32
33
|
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Helpers
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Resolve a data value to a pixel position based on channel type and scale. */
|
|
39
|
+
function resolvePosition(
|
|
40
|
+
value: unknown,
|
|
41
|
+
channelType: FieldType,
|
|
42
|
+
scale:
|
|
43
|
+
| ScaleLinear<number, number>
|
|
44
|
+
| ScaleTime<number, number>
|
|
45
|
+
| ScaleBand<string>
|
|
46
|
+
| ScalePoint<string>,
|
|
47
|
+
): number | undefined {
|
|
48
|
+
switch (channelType) {
|
|
49
|
+
case 'nominal':
|
|
50
|
+
case 'ordinal': {
|
|
51
|
+
const s = String(value);
|
|
52
|
+
if ('bandwidth' in scale && typeof scale.bandwidth === 'function') {
|
|
53
|
+
const bw = (scale as ScaleBand<string>).bandwidth();
|
|
54
|
+
const pos = (scale as ScaleBand<string>)(s);
|
|
55
|
+
if (pos === undefined) return undefined;
|
|
56
|
+
// ScalePoint has bandwidth() === 0; ScaleBand has > 0.
|
|
57
|
+
return bw > 0 ? pos + bw / 2 : pos;
|
|
58
|
+
}
|
|
59
|
+
return (scale as ScalePoint<string>)(s);
|
|
60
|
+
}
|
|
61
|
+
case 'temporal': {
|
|
62
|
+
const px = (scale as ScaleTime<number, number>)(new Date(value as string | number));
|
|
63
|
+
return Number.isNaN(px) ? undefined : px;
|
|
64
|
+
}
|
|
65
|
+
default: {
|
|
66
|
+
const num = Number(value);
|
|
67
|
+
if (!Number.isFinite(num)) return undefined;
|
|
68
|
+
return (scale as ScaleLinear<number, number>)(num);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
33
73
|
// ---------------------------------------------------------------------------
|
|
34
74
|
// Public API
|
|
35
75
|
// ---------------------------------------------------------------------------
|
|
@@ -37,8 +77,9 @@ const MAX_BUBBLE_RADIUS = 30;
|
|
|
37
77
|
/**
|
|
38
78
|
* Compute scatter/bubble marks from a normalized chart spec.
|
|
39
79
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
80
|
+
* Axes accept any field type: quantitative (linear), temporal (time),
|
|
81
|
+
* nominal/ordinal (band or point scale). Optional size encoding maps a
|
|
82
|
+
* data field to point radius using sqrt scale (area-proportional).
|
|
42
83
|
* Optional color encoding groups points by category with distinct colors.
|
|
43
84
|
*/
|
|
44
85
|
export function computeScatterMarks(
|
|
@@ -55,8 +96,18 @@ export function computeScatterMarks(
|
|
|
55
96
|
return [];
|
|
56
97
|
}
|
|
57
98
|
|
|
58
|
-
const xScale = scales.x.scale as
|
|
59
|
-
|
|
99
|
+
const xScale = scales.x.scale as
|
|
100
|
+
| ScaleLinear<number, number>
|
|
101
|
+
| ScaleTime<number, number>
|
|
102
|
+
| ScaleBand<string>
|
|
103
|
+
| ScalePoint<string>;
|
|
104
|
+
const yScale = scales.y.scale as
|
|
105
|
+
| ScaleLinear<number, number>
|
|
106
|
+
| ScaleTime<number, number>
|
|
107
|
+
| ScaleBand<string>
|
|
108
|
+
| ScalePoint<string>;
|
|
109
|
+
const xType = xChannel.type;
|
|
110
|
+
const yType = yChannel.type;
|
|
60
111
|
|
|
61
112
|
const colorEnc = encoding.color && 'field' in encoding.color ? encoding.color : undefined;
|
|
62
113
|
const isSequentialColor = colorEnc?.type === 'quantitative';
|
|
@@ -79,13 +130,13 @@ export function computeScatterMarks(
|
|
|
79
130
|
const marks: PointMark[] = [];
|
|
80
131
|
|
|
81
132
|
for (const row of spec.data) {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
133
|
+
const rawX = row[xChannel.field];
|
|
134
|
+
const rawY = row[yChannel.field];
|
|
84
135
|
|
|
85
|
-
|
|
136
|
+
const cx = resolvePosition(rawX, xType, xScale);
|
|
137
|
+
const cy = resolvePosition(rawY, yType, yScale);
|
|
86
138
|
|
|
87
|
-
|
|
88
|
-
const cy = yScale(yVal);
|
|
139
|
+
if (cx === undefined || cy === undefined) continue;
|
|
89
140
|
|
|
90
141
|
const category = colorField && !isSequentialColor ? String(row[colorField] ?? '') : undefined;
|
|
91
142
|
let color: string;
|
|
@@ -106,7 +157,7 @@ export function computeScatterMarks(
|
|
|
106
157
|
}
|
|
107
158
|
}
|
|
108
159
|
|
|
109
|
-
const labelParts = [`${xChannel.field}=${
|
|
160
|
+
const labelParts = [`${xChannel.field}=${rawX}`, `${yChannel.field}=${rawY}`];
|
|
110
161
|
if (category) labelParts.push(`${colorField}=${category}`);
|
|
111
162
|
if (sizeField && row[sizeField] != null) {
|
|
112
163
|
labelParts.push(`${sizeField}=${row[sizeField]}`);
|
package/src/compile.ts
CHANGED
|
@@ -72,6 +72,7 @@ const builtinRenderers: Record<string, ChartRenderer> = {
|
|
|
72
72
|
arc: pieRenderer, // old 'pie' (donut handled via innerRadius)
|
|
73
73
|
'arc:donut': donutRenderer, // old 'donut'
|
|
74
74
|
circle: dotRenderer, // old 'dot'
|
|
75
|
+
lollipop: dotRenderer, // semantic alias for dot/circle
|
|
75
76
|
text: textRenderer,
|
|
76
77
|
rule: ruleRenderer,
|
|
77
78
|
tick: tickRenderer,
|
|
@@ -442,6 +443,7 @@ export function compileChart(spec: unknown, options: CompileOptions): ChartLayou
|
|
|
442
443
|
strategy,
|
|
443
444
|
theme.isDark,
|
|
444
445
|
obstacles,
|
|
446
|
+
{ width: dims.total.width, height: dims.total.height },
|
|
445
447
|
);
|
|
446
448
|
|
|
447
449
|
// Compute tooltip descriptors from marks and encoding
|
|
@@ -272,6 +272,40 @@ describe('validateSpec', () => {
|
|
|
272
272
|
expect(fieldError!.code).toBe('MISSING_FIELD');
|
|
273
273
|
expect(fieldError!.suggestion).toContain('a');
|
|
274
274
|
});
|
|
275
|
+
|
|
276
|
+
it('accepts tooltip as an array of valid encoding channels', () => {
|
|
277
|
+
const result = validateSpec({
|
|
278
|
+
mark: 'bar',
|
|
279
|
+
data: [{ a: 1, b: 2, c: 3 }],
|
|
280
|
+
encoding: {
|
|
281
|
+
x: { field: 'a', type: 'quantitative' },
|
|
282
|
+
y: { field: 'b', type: 'nominal' },
|
|
283
|
+
tooltip: [
|
|
284
|
+
{ field: 'a', type: 'quantitative' },
|
|
285
|
+
{ field: 'c', type: 'quantitative' },
|
|
286
|
+
],
|
|
287
|
+
},
|
|
288
|
+
});
|
|
289
|
+
expect(result.valid).toBe(true);
|
|
290
|
+
expect(result.errors).toHaveLength(0);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('rejects tooltip array element with missing field', () => {
|
|
294
|
+
const result = validateSpec({
|
|
295
|
+
mark: 'bar',
|
|
296
|
+
data: [{ a: 1, b: 2 }],
|
|
297
|
+
encoding: {
|
|
298
|
+
x: { field: 'a', type: 'quantitative' },
|
|
299
|
+
y: { field: 'b', type: 'nominal' },
|
|
300
|
+
tooltip: [{ field: 'a', type: 'quantitative' }, { type: 'quantitative' }],
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
expect(result.valid).toBe(false);
|
|
304
|
+
const err = result.errors.find((e) => e.message.includes('tooltip[1]'));
|
|
305
|
+
expect(err).toBeDefined();
|
|
306
|
+
expect(err!.code).toBe('MISSING_FIELD');
|
|
307
|
+
expect(err!.path).toBe('encoding.tooltip[1].field');
|
|
308
|
+
});
|
|
275
309
|
});
|
|
276
310
|
|
|
277
311
|
describe('table specs', () => {
|
package/src/compiler/validate.ts
CHANGED
|
@@ -140,6 +140,40 @@ function validateChartSpec(spec: Record<string, unknown>, errors: ValidationErro
|
|
|
140
140
|
for (const [channel, channelSpec] of Object.entries(encoding)) {
|
|
141
141
|
if (!channelSpec || typeof channelSpec !== 'object') continue;
|
|
142
142
|
|
|
143
|
+
// Tooltip can be an array of encoding channels
|
|
144
|
+
if (channel === 'tooltip' && Array.isArray(channelSpec)) {
|
|
145
|
+
for (let i = 0; i < channelSpec.length; i++) {
|
|
146
|
+
const elem = channelSpec[i] as Record<string, unknown> | null;
|
|
147
|
+
if (!elem || typeof elem !== 'object') continue;
|
|
148
|
+
if (!elem.field || typeof elem.field !== 'string') {
|
|
149
|
+
errors.push({
|
|
150
|
+
message: `Spec error: encoding.tooltip[${i}] must have a "field" string`,
|
|
151
|
+
path: `encoding.tooltip[${i}].field`,
|
|
152
|
+
code: 'MISSING_FIELD',
|
|
153
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`,
|
|
154
|
+
});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!dataColumns.has(elem.field) && !transformFields.has(elem.field)) {
|
|
158
|
+
errors.push({
|
|
159
|
+
message: `Spec error: encoding.tooltip[${i}].field "${elem.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
160
|
+
path: `encoding.tooltip[${i}].field`,
|
|
161
|
+
code: 'DATA_FIELD_MISSING',
|
|
162
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (elem.type && !VALID_FIELD_TYPES.has(elem.type as string)) {
|
|
166
|
+
errors.push({
|
|
167
|
+
message: `Spec error: encoding.tooltip[${i}].type "${elem.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
168
|
+
path: `encoding.tooltip[${i}].type`,
|
|
169
|
+
code: 'INVALID_VALUE',
|
|
170
|
+
suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(', ')}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
143
177
|
const channelObj = channelSpec as Record<string, unknown>;
|
|
144
178
|
const channelRule = rules[channel as keyof typeof rules];
|
|
145
179
|
|
package/src/layout/axes.ts
CHANGED
|
@@ -255,7 +255,8 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
|
|
|
255
255
|
if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
|
|
256
256
|
const temporalFmt = buildTemporalFormatter(formatStr);
|
|
257
257
|
if (temporalFmt) return temporalFmt(value as Date);
|
|
258
|
-
|
|
258
|
+
const useUtc = resolvedScale.type === 'utc';
|
|
259
|
+
return formatDate(value as Date, undefined, undefined, useUtc);
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
|
package/src/layout/dimensions.ts
CHANGED
|
@@ -190,6 +190,7 @@ export function computeDimensions(
|
|
|
190
190
|
|
|
191
191
|
// Reserve right margin for text annotations near the chart's right edge.
|
|
192
192
|
// Without this, annotation text at the last data point clips outside the SVG.
|
|
193
|
+
// Account for anchor direction and offset.dx to avoid over-reserving space.
|
|
193
194
|
if (spec.annotations.length > 0 && encoding.x) {
|
|
194
195
|
const xField = encoding.x.field;
|
|
195
196
|
// Find the maximum x value in the data
|
|
@@ -203,7 +204,23 @@ export function computeDimensions(
|
|
|
203
204
|
for (const ann of spec.annotations) {
|
|
204
205
|
if (ann.type === 'text' && String(ann.x) === maxXStr) {
|
|
205
206
|
const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600);
|
|
206
|
-
|
|
207
|
+
const dx = ann.offset?.dx ?? 0;
|
|
208
|
+
// How much text extends right of the anchor point depends on alignment:
|
|
209
|
+
// - anchor "right" or "left": text is off to one side, full width extends
|
|
210
|
+
// - anchor "top"/"bottom"/"auto"/undefined: text is centered, half extends right
|
|
211
|
+
const anchor = ann.anchor ?? 'auto';
|
|
212
|
+
const baseRightExtent =
|
|
213
|
+
anchor === 'left'
|
|
214
|
+
? textWidth
|
|
215
|
+
: // text is to the right of anchor
|
|
216
|
+
anchor === 'right'
|
|
217
|
+
? 0
|
|
218
|
+
: // text is to the left of anchor
|
|
219
|
+
textWidth / 2; // centered (top/bottom/auto)
|
|
220
|
+
const rightOverflow = Math.max(0, baseRightExtent + dx);
|
|
221
|
+
if (rightOverflow > 0) {
|
|
222
|
+
margins.right = Math.max(margins.right, padding + rightOverflow + 12);
|
|
223
|
+
}
|
|
207
224
|
}
|
|
208
225
|
}
|
|
209
226
|
}
|
|
@@ -214,6 +231,7 @@ export function computeDimensions(
|
|
|
214
231
|
if (
|
|
215
232
|
spec.markType === 'bar' ||
|
|
216
233
|
spec.markType === 'circle' ||
|
|
234
|
+
spec.markType === 'lollipop' ||
|
|
217
235
|
encoding.y.type === 'nominal' ||
|
|
218
236
|
encoding.y.type === 'ordinal'
|
|
219
237
|
) {
|
package/src/layout/scales.ts
CHANGED
|
@@ -568,7 +568,10 @@ function buildPositionalScale(
|
|
|
568
568
|
case 'nominal':
|
|
569
569
|
case 'ordinal':
|
|
570
570
|
// Bar charts use band scales for their categorical axis (both orientations)
|
|
571
|
-
if (
|
|
571
|
+
if (
|
|
572
|
+
chartType === 'bar' ||
|
|
573
|
+
((chartType === 'circle' || chartType === 'lollipop') && axis === 'y')
|
|
574
|
+
) {
|
|
572
575
|
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
573
576
|
}
|
|
574
577
|
return buildPointScale(channel, data, rangeStart, rangeEnd);
|
package/src/legend/compute.ts
CHANGED
|
@@ -51,6 +51,7 @@ function swatchShapeForType(markType: string): LegendEntry['shape'] {
|
|
|
51
51
|
return 'line';
|
|
52
52
|
case 'point':
|
|
53
53
|
case 'circle':
|
|
54
|
+
case 'lollipop':
|
|
54
55
|
return 'circle';
|
|
55
56
|
default:
|
|
56
57
|
return 'square';
|
|
@@ -213,10 +214,15 @@ export function computeLegend(
|
|
|
213
214
|
const maxLegendHeight = chartArea.height * maxHeightRatio;
|
|
214
215
|
|
|
215
216
|
// Calculate how many entries fit
|
|
216
|
-
const
|
|
217
|
+
const maxFromSpace = Math.max(
|
|
217
218
|
1,
|
|
218
219
|
Math.floor((maxLegendHeight - LEGEND_PADDING * 2) / (entryHeight + 4)),
|
|
219
220
|
);
|
|
221
|
+
// symbolLimit overrides the space-based limit when set (minimum 1)
|
|
222
|
+
const maxEntries =
|
|
223
|
+
spec.legend?.symbolLimit != null
|
|
224
|
+
? Math.min(Math.max(1, spec.legend.symbolLimit), maxFromSpace)
|
|
225
|
+
: maxFromSpace;
|
|
220
226
|
if (entries.length > maxEntries) {
|
|
221
227
|
entries = truncateEntries(entries, maxEntries);
|
|
222
228
|
}
|
|
@@ -254,7 +260,21 @@ export function computeLegend(
|
|
|
254
260
|
// Top/bottom-positioned legend: horizontal flow with overflow protection.
|
|
255
261
|
// Reserve space on the right so legend entries don't overlap the brand watermark.
|
|
256
262
|
const availableWidth = chartArea.width - LEGEND_PADDING * 2 - BRAND_RESERVE_WIDTH;
|
|
257
|
-
|
|
263
|
+
|
|
264
|
+
// Apply symbolLimit first if set (minimum 1), then fit remaining entries to available rows.
|
|
265
|
+
if (spec.legend?.symbolLimit != null) {
|
|
266
|
+
const limit = Math.max(1, spec.legend.symbolLimit);
|
|
267
|
+
if (limit < entries.length) {
|
|
268
|
+
entries = truncateEntries(entries, limit);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// When columns is explicitly set, allow that many rows instead of the default max.
|
|
273
|
+
const maxRows =
|
|
274
|
+
spec.legend?.columns != null
|
|
275
|
+
? Math.ceil(entries.length / spec.legend.columns)
|
|
276
|
+
: TOP_LEGEND_MAX_ROWS;
|
|
277
|
+
const maxFit = entriesThatFit(entries, availableWidth, maxRows, labelStyle);
|
|
258
278
|
|
|
259
279
|
if (maxFit < entries.length) {
|
|
260
280
|
entries = truncateEntries(entries, maxFit);
|
|
@@ -323,6 +323,67 @@ describe('computeTooltipDescriptors', () => {
|
|
|
323
323
|
});
|
|
324
324
|
});
|
|
325
325
|
|
|
326
|
+
describe('explicit tooltip encoding', () => {
|
|
327
|
+
it('uses tooltip array fields instead of auto-generated defaults', () => {
|
|
328
|
+
const spec: NormalizedChartSpec = {
|
|
329
|
+
...makeBarSpec(),
|
|
330
|
+
encoding: {
|
|
331
|
+
...makeBarSpec().encoding,
|
|
332
|
+
tooltip: [
|
|
333
|
+
{ field: 'category', type: 'nominal' },
|
|
334
|
+
{ field: 'value', type: 'quantitative' },
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
const rectMarks: RectMark[] = [
|
|
339
|
+
{
|
|
340
|
+
type: 'rect',
|
|
341
|
+
x: 50,
|
|
342
|
+
y: 30,
|
|
343
|
+
width: 200,
|
|
344
|
+
height: 40,
|
|
345
|
+
fill: '#1b7fa3',
|
|
346
|
+
data: { category: 'A', value: 100 },
|
|
347
|
+
aria: { label: 'bar' },
|
|
348
|
+
},
|
|
349
|
+
];
|
|
350
|
+
|
|
351
|
+
const descriptors = computeTooltipDescriptors(spec, rectMarks);
|
|
352
|
+
const content = descriptors.get('rect-0')!;
|
|
353
|
+
|
|
354
|
+
expect(content.fields).toHaveLength(2);
|
|
355
|
+
expect(content.fields[0].label).toBe('category');
|
|
356
|
+
expect(content.fields[0].value).toBe('A');
|
|
357
|
+
expect(content.fields[1].label).toBe('value');
|
|
358
|
+
expect(content.fields[1].value).toBe('100');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('auto-generates tooltip fields when encoding.tooltip is not set', () => {
|
|
362
|
+
const spec = makeBarSpec();
|
|
363
|
+
const rectMarks: RectMark[] = [
|
|
364
|
+
{
|
|
365
|
+
type: 'rect',
|
|
366
|
+
x: 50,
|
|
367
|
+
y: 30,
|
|
368
|
+
width: 200,
|
|
369
|
+
height: 40,
|
|
370
|
+
fill: '#1b7fa3',
|
|
371
|
+
data: { category: 'A', value: 100 },
|
|
372
|
+
aria: { label: 'bar' },
|
|
373
|
+
},
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
const descriptors = computeTooltipDescriptors(spec, rectMarks);
|
|
377
|
+
const content = descriptors.get('rect-0')!;
|
|
378
|
+
|
|
379
|
+
// Default: y field first, then x field
|
|
380
|
+
expect(content.fields.length).toBeGreaterThanOrEqual(1);
|
|
381
|
+
const labels = content.fields.map((f) => f.label);
|
|
382
|
+
expect(labels).toContain('value');
|
|
383
|
+
expect(labels).toContain('category');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
326
387
|
describe('empty data', () => {
|
|
327
388
|
it('returns empty map for no marks', () => {
|
|
328
389
|
const spec = makeLineSpec();
|
package/src/tooltips/compute.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type {
|
|
|
11
11
|
AreaMark,
|
|
12
12
|
DataRow,
|
|
13
13
|
Encoding,
|
|
14
|
+
EncodingChannel,
|
|
14
15
|
LineMark,
|
|
15
16
|
Mark,
|
|
16
17
|
PointMark,
|
|
@@ -51,8 +52,21 @@ function formatValue(value: unknown, fieldType?: string, format?: string): strin
|
|
|
51
52
|
return String(value);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/** Build tooltip fields from explicit tooltip encoding channels. */
|
|
56
|
+
function buildExplicitTooltipFields(row: DataRow, channels: EncodingChannel[]): TooltipField[] {
|
|
57
|
+
return channels.map((ch) => ({
|
|
58
|
+
label: ch.axis?.label ?? ch.field,
|
|
59
|
+
value: formatValue(row[ch.field], ch.type, ch.axis?.format),
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
54
63
|
/** Build tooltip fields from a data row based on the spec encoding. */
|
|
55
64
|
function buildFields(row: DataRow, encoding: Encoding, color?: string): TooltipField[] {
|
|
65
|
+
if (encoding.tooltip) {
|
|
66
|
+
const channels = Array.isArray(encoding.tooltip) ? encoding.tooltip : [encoding.tooltip];
|
|
67
|
+
return buildExplicitTooltipFields(row, channels);
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
const fields: TooltipField[] = [];
|
|
57
71
|
|
|
58
72
|
// Y-axis value (the "main" value in most charts)
|