@opendata-ai/openchart-engine 6.0.0 → 6.1.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.
Files changed (65) hide show
  1. package/dist/index.d.ts +155 -19
  2. package/dist/index.js +1513 -164
  3. package/dist/index.js.map +1 -1
  4. package/package.json +2 -2
  5. package/src/__test-fixtures__/specs.ts +6 -3
  6. package/src/__tests__/axes.test.ts +168 -4
  7. package/src/__tests__/compile-chart.test.ts +23 -12
  8. package/src/__tests__/compile-layer.test.ts +386 -0
  9. package/src/__tests__/dimensions.test.ts +6 -3
  10. package/src/__tests__/legend.test.ts +6 -3
  11. package/src/__tests__/scales.test.ts +176 -2
  12. package/src/annotations/__tests__/compute.test.ts +8 -4
  13. package/src/charts/bar/__tests__/compute.test.ts +12 -6
  14. package/src/charts/bar/compute.ts +21 -5
  15. package/src/charts/column/__tests__/compute.test.ts +14 -7
  16. package/src/charts/column/compute.ts +21 -6
  17. package/src/charts/dot/__tests__/compute.test.ts +10 -5
  18. package/src/charts/dot/compute.ts +10 -4
  19. package/src/charts/line/__tests__/compute.test.ts +102 -11
  20. package/src/charts/line/__tests__/curves.test.ts +51 -0
  21. package/src/charts/line/__tests__/labels.test.ts +2 -1
  22. package/src/charts/line/__tests__/mark-options.test.ts +175 -0
  23. package/src/charts/line/area.ts +19 -8
  24. package/src/charts/line/compute.ts +64 -25
  25. package/src/charts/line/curves.ts +40 -0
  26. package/src/charts/pie/__tests__/compute.test.ts +10 -5
  27. package/src/charts/pie/compute.ts +2 -1
  28. package/src/charts/rule/index.ts +127 -0
  29. package/src/charts/scatter/__tests__/compute.test.ts +10 -5
  30. package/src/charts/scatter/compute.ts +15 -5
  31. package/src/charts/text/index.ts +92 -0
  32. package/src/charts/tick/index.ts +84 -0
  33. package/src/charts/utils.ts +1 -1
  34. package/src/compile.ts +175 -23
  35. package/src/compiler/__tests__/compile.test.ts +4 -4
  36. package/src/compiler/__tests__/normalize.test.ts +4 -4
  37. package/src/compiler/__tests__/validate.test.ts +25 -26
  38. package/src/compiler/index.ts +1 -1
  39. package/src/compiler/normalize.ts +77 -4
  40. package/src/compiler/types.ts +6 -2
  41. package/src/compiler/validate.ts +167 -35
  42. package/src/graphs/__tests__/compile-graph.test.ts +2 -2
  43. package/src/graphs/compile-graph.ts +2 -2
  44. package/src/index.ts +17 -1
  45. package/src/layout/axes.ts +122 -20
  46. package/src/layout/dimensions.ts +15 -9
  47. package/src/layout/scales.ts +320 -31
  48. package/src/legend/compute.ts +9 -6
  49. package/src/tables/__tests__/compile-table.test.ts +1 -1
  50. package/src/tooltips/__tests__/compute.test.ts +10 -5
  51. package/src/tooltips/compute.ts +32 -14
  52. package/src/transforms/__tests__/bin.test.ts +88 -0
  53. package/src/transforms/__tests__/calculate.test.ts +146 -0
  54. package/src/transforms/__tests__/conditional.test.ts +109 -0
  55. package/src/transforms/__tests__/filter.test.ts +59 -0
  56. package/src/transforms/__tests__/index.test.ts +93 -0
  57. package/src/transforms/__tests__/predicates.test.ts +176 -0
  58. package/src/transforms/__tests__/timeunit.test.ts +129 -0
  59. package/src/transforms/bin.ts +87 -0
  60. package/src/transforms/calculate.ts +60 -0
  61. package/src/transforms/conditional.ts +46 -0
  62. package/src/transforms/filter.ts +17 -0
  63. package/src/transforms/index.ts +48 -0
  64. package/src/transforms/predicates.ts +90 -0
  65. package/src/transforms/timeunit.ts +88 -0
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // Main compile API
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- export { compileChart, compileGraph, compileTable } from './compile';
15
+ export { compileChart, compileGraph, compileLayer, compileTable } from './compile';
16
16
 
17
17
  // ---------------------------------------------------------------------------
18
18
  // Graph compilation types
@@ -57,6 +57,21 @@ export {
57
57
  registerChartRenderer,
58
58
  } from './charts/registry';
59
59
 
60
+ // ---------------------------------------------------------------------------
61
+ // Data transforms
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export {
65
+ evaluatePredicate,
66
+ isConditionalValueDef,
67
+ resolveConditionalValue,
68
+ runBin,
69
+ runCalculate,
70
+ runFilter,
71
+ runTimeUnit,
72
+ runTransforms,
73
+ } from './transforms';
74
+
60
75
  // ---------------------------------------------------------------------------
61
76
  // Re-export core types for convenience
62
77
  // ---------------------------------------------------------------------------
@@ -68,6 +83,7 @@ export type {
68
83
  CompileTableOptions,
69
84
  GraphLayout,
70
85
  GraphSpec,
86
+ LayerSpec,
71
87
  TableLayout,
72
88
  TableSpec,
73
89
  VizSpec,
@@ -172,9 +172,21 @@ export function thinTicksUntilFit(
172
172
  // Tick generation
173
173
  // ---------------------------------------------------------------------------
174
174
 
175
- /** Generate ticks for a continuous scale (linear, time, log). */
175
+ /** Generate ticks for a continuous scale (linear, time, log, pow, sqrt, symlog). */
176
176
  function continuousTicks(resolvedScale: ResolvedScale, density: AxisLabelDensity): AxisTick[] {
177
177
  const scale = resolvedScale.scale as D3ContinuousScale;
178
+
179
+ // Discretizing scales (quantile, quantize, threshold) don't have .ticks().
180
+ // Use their domain thresholds as ticks instead.
181
+ if (!('ticks' in scale) || typeof scale.ticks !== 'function') {
182
+ const domain = scale.domain() as unknown[];
183
+ return domain.map((value: unknown) => ({
184
+ value,
185
+ position: (scale as D3ContinuousScale)(value as number & Date) as number,
186
+ label: formatTickLabel(value, resolvedScale),
187
+ }));
188
+ }
189
+
178
190
  const explicitCount = resolvedScale.channel.axis?.tickCount;
179
191
  const count = explicitCount ?? TICK_COUNTS[density];
180
192
  const rawTicks: unknown[] = scale.ticks(count);
@@ -220,16 +232,31 @@ function categoricalTicks(resolvedScale: ResolvedScale, density: AxisLabelDensit
220
232
  return ticks;
221
233
  }
222
234
 
235
+ /** Set of continuous numeric scale types that should format as numbers. */
236
+ const NUMERIC_SCALE_TYPES = new Set([
237
+ 'linear',
238
+ 'log',
239
+ 'pow',
240
+ 'sqrt',
241
+ 'symlog',
242
+ 'quantile',
243
+ 'quantize',
244
+ 'threshold',
245
+ ]);
246
+
247
+ /** Set of temporal scale types. */
248
+ const TEMPORAL_SCALE_TYPES = new Set(['time', 'utc']);
249
+
223
250
  /** Format a tick value based on the scale type. */
224
251
  function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
225
252
  const formatStr = resolvedScale.channel.axis?.format;
226
253
 
227
- if (resolvedScale.type === 'time') {
254
+ if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
228
255
  if (formatStr) return String(value); // Custom format not implemented yet
229
256
  return formatDate(value as Date);
230
257
  }
231
258
 
232
- if (resolvedScale.type === 'linear' || resolvedScale.type === 'log') {
259
+ if (NUMERIC_SCALE_TYPES.has(resolvedScale.type)) {
233
260
  const num = value as number;
234
261
  if (formatStr) {
235
262
  const fmt = buildD3Formatter(formatStr);
@@ -243,6 +270,35 @@ function formatTickLabel(value: unknown, resolvedScale: ResolvedScale): string {
243
270
  return String(value);
244
271
  }
245
272
 
273
+ /** Resolve explicit tick values from axis config into positioned ticks. */
274
+ function resolveExplicitTicks(values: unknown[], resolvedScale: ResolvedScale): AxisTick[] {
275
+ const scale = resolvedScale.scale;
276
+ return values.map((value) => {
277
+ let position: number;
278
+ if (TEMPORAL_SCALE_TYPES.has(resolvedScale.type)) {
279
+ const d = value instanceof Date ? value : new Date(String(value));
280
+ position = (scale as D3ContinuousScale)(d as number & Date) as number;
281
+ } else if (
282
+ resolvedScale.type === 'band' ||
283
+ resolvedScale.type === 'point' ||
284
+ resolvedScale.type === 'ordinal'
285
+ ) {
286
+ const s = String(value);
287
+ const bandScale = resolvedScale.type === 'band' ? (scale as ScaleBand<string>) : null;
288
+ position = bandScale
289
+ ? (bandScale(s) ?? 0) + bandScale.bandwidth() / 2
290
+ : ((scale(s as string & number) as number | undefined) ?? 0);
291
+ } else {
292
+ position = (scale as D3ContinuousScale)(value as number & Date) as number;
293
+ }
294
+ return {
295
+ value,
296
+ position,
297
+ label: formatTickLabel(value, resolvedScale),
298
+ };
299
+ });
300
+ }
301
+
246
302
  // ---------------------------------------------------------------------------
247
303
  // Public API
248
304
  // ---------------------------------------------------------------------------
@@ -308,10 +364,21 @@ export function computeAxes(
308
364
  const { fontWeight } = tickLabelStyle;
309
365
 
310
366
  if (scales.x) {
311
- const allTicks =
312
- scales.x.type === 'band' || scales.x.type === 'point' || scales.x.type === 'ordinal'
313
- ? categoricalTicks(scales.x, xDensity)
314
- : continuousTicks(scales.x, xDensity);
367
+ const axisConfig = scales.x.channel.axis;
368
+
369
+ // Use explicit tick values from axis config if provided
370
+ let allTicks: AxisTick[];
371
+ if (axisConfig?.values) {
372
+ allTicks = resolveExplicitTicks(axisConfig.values, scales.x);
373
+ } else if (
374
+ scales.x.type === 'band' ||
375
+ scales.x.type === 'point' ||
376
+ scales.x.type === 'ordinal'
377
+ ) {
378
+ allTicks = categoricalTicks(scales.x, xDensity);
379
+ } else {
380
+ allTicks = continuousTicks(scales.x, xDensity);
381
+ }
315
382
 
316
383
  // Gridlines use the full tick set so they remain visible even when labels
317
384
  // are thinned to prevent overlap.
@@ -321,15 +388,16 @@ export function computeAxes(
321
388
  }));
322
389
 
323
390
  // Thin tick labels to prevent overlap (skip for band scales which use
324
- // auto-rotation, and when the user set an explicit tickCount).
325
- const shouldThin = scales.x.type !== 'band' && !scales.x.channel.axis?.tickCount;
391
+ // auto-rotation, and when the user set an explicit tickCount or values).
392
+ const shouldThin = scales.x.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
326
393
  const ticks = shouldThin
327
394
  ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
328
395
  : allTicks;
329
396
 
330
397
  // Auto-rotate labels when band scale labels would overlap.
331
398
  // Uses max label width (not average) since one long label is enough to overlap.
332
- let tickAngle = scales.x.channel.axis?.tickAngle;
399
+ // Prefer labelAngle over deprecated tickAngle
400
+ let tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
333
401
  if (tickAngle === undefined && scales.x.type === 'band' && ticks.length > 1) {
334
402
  const bandwidth = (scales.x.scale as ScaleBand<string>).bandwidth();
335
403
  let maxLabelWidth = 0;
@@ -343,23 +411,45 @@ export function computeAxes(
343
411
  }
344
412
  }
345
413
 
414
+ // Prefer title over deprecated label
415
+ const axisTitle = axisConfig?.title ?? axisConfig?.label;
416
+
346
417
  result.x = {
347
418
  ticks,
348
- gridlines: scales.x.channel.axis?.grid ? gridlines : [],
349
- label: scales.x.channel.axis?.label,
419
+ gridlines: axisConfig?.grid ? gridlines : [],
420
+ label: axisTitle,
350
421
  labelStyle: axisLabelStyle,
351
422
  tickLabelStyle,
352
423
  tickAngle,
353
424
  start: { x: chartArea.x, y: chartArea.y + chartArea.height },
354
425
  end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height },
426
+ orient: axisConfig?.orient,
427
+ domainLine: axisConfig?.domain,
428
+ tickMarks: axisConfig?.ticks,
429
+ offset: axisConfig?.offset,
430
+ titlePadding: axisConfig?.titlePadding,
431
+ labelPadding: axisConfig?.labelPadding,
432
+ labelOverlap: axisConfig?.labelOverlap,
433
+ labelFlush: axisConfig?.labelFlush,
355
434
  };
356
435
  }
357
436
 
358
437
  if (scales.y) {
359
- const allTicks =
360
- scales.y.type === 'band' || scales.y.type === 'point' || scales.y.type === 'ordinal'
361
- ? categoricalTicks(scales.y, yDensity)
362
- : continuousTicks(scales.y, yDensity);
438
+ const axisConfig = scales.y.channel.axis;
439
+
440
+ // Use explicit tick values from axis config if provided
441
+ let allTicks: AxisTick[];
442
+ if (axisConfig?.values) {
443
+ allTicks = resolveExplicitTicks(axisConfig.values, scales.y);
444
+ } else if (
445
+ scales.y.type === 'band' ||
446
+ scales.y.type === 'point' ||
447
+ scales.y.type === 'ordinal'
448
+ ) {
449
+ allTicks = categoricalTicks(scales.y, yDensity);
450
+ } else {
451
+ allTicks = continuousTicks(scales.y, yDensity);
452
+ }
363
453
 
364
454
  // Gridlines use the full tick set (label thinning shouldn't remove gridlines).
365
455
  const gridlines: Gridline[] = allTicks.map((t) => ({
@@ -367,22 +457,34 @@ export function computeAxes(
367
457
  major: true,
368
458
  }));
369
459
 
370
- // Thin tick labels to prevent overlap (skip for band scales and explicit tickCount).
371
- const shouldThin = scales.y.type !== 'band' && !scales.y.channel.axis?.tickCount;
460
+ // Thin tick labels to prevent overlap (skip for band scales, explicit tickCount, and values).
461
+ const shouldThin = scales.y.type !== 'band' && !axisConfig?.tickCount && !axisConfig?.values;
372
462
  const ticks = shouldThin
373
463
  ? thinTicksUntilFit(allTicks, fontSize, fontWeight, measureText)
374
464
  : allTicks;
375
465
 
466
+ // Prefer title over deprecated label, labelAngle over deprecated tickAngle
467
+ const axisTitle = axisConfig?.title ?? axisConfig?.label;
468
+ const tickAngle = axisConfig?.labelAngle ?? axisConfig?.tickAngle;
469
+
376
470
  result.y = {
377
471
  ticks,
378
472
  // Y-axis gridlines are shown by default (standard editorial practice)
379
473
  gridlines,
380
- label: scales.y.channel.axis?.label,
474
+ label: axisTitle,
381
475
  labelStyle: axisLabelStyle,
382
476
  tickLabelStyle,
383
- tickAngle: scales.y.channel.axis?.tickAngle,
477
+ tickAngle,
384
478
  start: { x: chartArea.x, y: chartArea.y },
385
479
  end: { x: chartArea.x, y: chartArea.y + chartArea.height },
480
+ orient: axisConfig?.orient,
481
+ domainLine: axisConfig?.domain,
482
+ tickMarks: axisConfig?.ticks,
483
+ offset: axisConfig?.offset,
484
+ titlePadding: axisConfig?.titlePadding,
485
+ labelPadding: axisConfig?.labelPadding,
486
+ labelOverlap: axisConfig?.labelOverlap,
487
+ labelFlush: axisConfig?.labelFlush,
386
488
  };
387
489
  }
388
490
 
@@ -115,8 +115,8 @@ export function computeDimensions(
115
115
  // Start with the total rect
116
116
  const total: Rect = { x: 0, y: 0, width, height };
117
117
 
118
- // Radial charts (pie/donut) don't have axes, so skip axis space
119
- const isRadial = spec.type === 'pie' || spec.type === 'donut';
118
+ // Radial charts (arc) don't have axes, so skip axis space
119
+ const isRadial = spec.markType === 'arc';
120
120
  const encoding = spec.encoding as Encoding;
121
121
 
122
122
  // Estimate x-axis height below chart area: tick labels sit 14px below,
@@ -151,9 +151,13 @@ export function computeDimensions(
151
151
  xAxisHeight = hasXAxisLabel ? 48 : 26;
152
152
  }
153
153
 
154
- // Build margins: padding + chrome + axis space
154
+ // Build margins: padding + chrome + axis space.
155
+ // For radial charts (arc/donut), axes don't exist, so axisMargin is only
156
+ // added when there's actual chrome content that needs separation from the
157
+ // chart area. When chrome is empty the margin is just padding.
158
+ const topAxisGap = isRadial && chrome.topHeight === 0 ? 0 : axisMargin;
155
159
  const margins: Margins = {
156
- top: padding + chrome.topHeight + axisMargin,
160
+ top: padding + chrome.topHeight + topAxisGap,
157
161
  right: padding + (isRadial ? padding : axisMargin),
158
162
  bottom: padding + chrome.bottomHeight + xAxisHeight,
159
163
  left: padding + (isRadial ? padding : axisMargin),
@@ -162,9 +166,10 @@ export function computeDimensions(
162
166
  // Dynamic right margin for line/area end-of-line labels.
163
167
  // Only reserve space when labels will actually render (density != 'none').
164
168
  const labelDensity = spec.labels.density;
165
- if ((spec.type === 'line' || spec.type === 'area') && labelDensity !== 'none') {
169
+ if ((spec.markType === 'line' || spec.markType === 'area') && labelDensity !== 'none') {
166
170
  // Estimate label width from longest series name (color encoding domain)
167
- const colorField = encoding.color?.field;
171
+ const colorEnc = encoding.color;
172
+ const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined;
168
173
  if (colorField) {
169
174
  let maxLabelWidth = 0;
170
175
  const seen = new Set<string>();
@@ -185,8 +190,8 @@ export function computeDimensions(
185
190
  // Dynamic left margin for y-axis labels
186
191
  if (encoding.y && !isRadial) {
187
192
  if (
188
- spec.type === 'bar' ||
189
- spec.type === 'dot' ||
193
+ spec.markType === 'bar' ||
194
+ spec.markType === 'circle' ||
190
195
  encoding.y.type === 'nominal' ||
191
196
  encoding.y.type === 'ordinal'
192
197
  ) {
@@ -272,7 +277,8 @@ export function computeDimensions(
272
277
  );
273
278
 
274
279
  // Recalculate top/bottom margins with stripped chrome
275
- const newTop = padding + fallbackChrome.topHeight + axisMargin;
280
+ const fallbackTopAxisGap = isRadial && fallbackChrome.topHeight === 0 ? 0 : axisMargin;
281
+ const newTop = padding + fallbackChrome.topHeight + fallbackTopAxisGap;
276
282
  const topDelta = margins.top - newTop;
277
283
  const newBottom = padding + fallbackChrome.bottomHeight + xAxisHeight;
278
284
  const bottomDelta = margins.bottom - newBottom;