@opendata-ai/openchart-engine 6.2.1 → 6.4.1

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.
@@ -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. Both axes are quantitative.
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
- * Both x and y are quantitative (linear scales). Optional size encoding
41
- * maps a data field to point radius using sqrt scale (area-proportional).
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 ScaleLinear<number, number>;
59
- const yScale = scales.y.scale as ScaleLinear<number, number>;
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 xVal = Number(row[xChannel.field]);
83
- const yVal = Number(row[yChannel.field]);
133
+ const rawX = row[xChannel.field];
134
+ const rawY = row[yChannel.field];
84
135
 
85
- if (!Number.isFinite(xVal) || !Number.isFinite(yVal)) continue;
136
+ const cx = resolvePosition(rawX, xType, xScale);
137
+ const cy = resolvePosition(rawY, yType, yScale);
86
138
 
87
- const cx = xScale(xVal);
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}=${xVal}`, `${yChannel.field}=${yVal}`];
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', () => {
@@ -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
 
@@ -231,6 +231,7 @@ export function computeDimensions(
231
231
  if (
232
232
  spec.markType === 'bar' ||
233
233
  spec.markType === 'circle' ||
234
+ spec.markType === 'lollipop' ||
234
235
  encoding.y.type === 'nominal' ||
235
236
  encoding.y.type === 'ordinal'
236
237
  ) {
@@ -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 (chartType === 'bar' || (chartType === 'circle' && axis === 'y')) {
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);
@@ -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 maxEntries = Math.max(
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
- const maxFit = entriesThatFit(entries, availableWidth, TOP_LEGEND_MAX_ROWS, labelStyle);
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();
@@ -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)