@opendata-ai/openchart-engine 1.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/dist/index.d.ts +366 -0
- package/dist/index.js +4227 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
- package/src/__test-fixtures__/specs.ts +124 -0
- package/src/__tests__/axes.test.ts +114 -0
- package/src/__tests__/compile-chart.test.ts +337 -0
- package/src/__tests__/dimensions.test.ts +151 -0
- package/src/__tests__/legend.test.ts +113 -0
- package/src/__tests__/scales.test.ts +109 -0
- package/src/annotations/__tests__/compute.test.ts +454 -0
- package/src/annotations/compute.ts +603 -0
- package/src/charts/__tests__/registry.test.ts +110 -0
- package/src/charts/bar/__tests__/compute.test.ts +294 -0
- package/src/charts/bar/__tests__/labels.test.ts +75 -0
- package/src/charts/bar/compute.ts +205 -0
- package/src/charts/bar/index.ts +33 -0
- package/src/charts/bar/labels.ts +132 -0
- package/src/charts/column/__tests__/compute.test.ts +277 -0
- package/src/charts/column/compute.ts +282 -0
- package/src/charts/column/index.ts +33 -0
- package/src/charts/column/labels.ts +108 -0
- package/src/charts/dot/__tests__/compute.test.ts +344 -0
- package/src/charts/dot/compute.ts +257 -0
- package/src/charts/dot/index.ts +46 -0
- package/src/charts/dot/labels.ts +97 -0
- package/src/charts/line/__tests__/compute.test.ts +437 -0
- package/src/charts/line/__tests__/labels.test.ts +93 -0
- package/src/charts/line/area.ts +288 -0
- package/src/charts/line/compute.ts +177 -0
- package/src/charts/line/index.ts +68 -0
- package/src/charts/line/labels.ts +144 -0
- package/src/charts/pie/__tests__/compute.test.ts +276 -0
- package/src/charts/pie/compute.ts +234 -0
- package/src/charts/pie/index.ts +49 -0
- package/src/charts/pie/labels.ts +142 -0
- package/src/charts/registry.ts +64 -0
- package/src/charts/scatter/__tests__/compute.test.ts +304 -0
- package/src/charts/scatter/__tests__/trendline.test.ts +191 -0
- package/src/charts/scatter/compute.ts +124 -0
- package/src/charts/scatter/index.ts +41 -0
- package/src/charts/scatter/trendline.ts +100 -0
- package/src/charts/utils.ts +120 -0
- package/src/compile.ts +368 -0
- package/src/compiler/__tests__/compile.test.ts +87 -0
- package/src/compiler/__tests__/normalize.test.ts +210 -0
- package/src/compiler/__tests__/validate.test.ts +440 -0
- package/src/compiler/index.ts +47 -0
- package/src/compiler/normalize.ts +269 -0
- package/src/compiler/types.ts +148 -0
- package/src/compiler/validate.ts +581 -0
- package/src/graphs/__tests__/community.test.ts +228 -0
- package/src/graphs/__tests__/compile-graph.test.ts +315 -0
- package/src/graphs/__tests__/encoding.test.ts +314 -0
- package/src/graphs/community.ts +92 -0
- package/src/graphs/compile-graph.ts +291 -0
- package/src/graphs/encoding.ts +302 -0
- package/src/graphs/types.ts +98 -0
- package/src/index.ts +74 -0
- package/src/layout/axes.ts +194 -0
- package/src/layout/dimensions.ts +199 -0
- package/src/layout/gridlines.ts +84 -0
- package/src/layout/scales.ts +426 -0
- package/src/legend/compute.ts +186 -0
- package/src/tables/__tests__/bar-column.test.ts +147 -0
- package/src/tables/__tests__/category-colors.test.ts +153 -0
- package/src/tables/__tests__/compile-table.test.ts +208 -0
- package/src/tables/__tests__/format-cells.test.ts +126 -0
- package/src/tables/__tests__/heatmap.test.ts +124 -0
- package/src/tables/__tests__/pagination.test.ts +78 -0
- package/src/tables/__tests__/search.test.ts +94 -0
- package/src/tables/__tests__/sort.test.ts +107 -0
- package/src/tables/__tests__/sparkline.test.ts +122 -0
- package/src/tables/bar-column.ts +94 -0
- package/src/tables/category-colors.ts +67 -0
- package/src/tables/compile-table.ts +420 -0
- package/src/tables/format-cells.ts +110 -0
- package/src/tables/heatmap.ts +121 -0
- package/src/tables/pagination.ts +46 -0
- package/src/tables/search.ts +66 -0
- package/src/tables/sort.ts +69 -0
- package/src/tables/sparkline.ts +113 -0
- package/src/tables/utils.ts +16 -0
- package/src/tooltips/__tests__/compute.test.ts +328 -0
- package/src/tooltips/compute.ts +231 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scatter / bubble chart module.
|
|
3
|
+
*
|
|
4
|
+
* Exports the scatter chart renderer and computation functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Mark } from '@opendata-ai/openchart-core';
|
|
8
|
+
import type { ChartRenderer } from '../registry';
|
|
9
|
+
import { computeScatterMarks } from './compute';
|
|
10
|
+
import { computeTrendLine } from './trendline';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Scatter chart renderer
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Scatter chart renderer.
|
|
18
|
+
*
|
|
19
|
+
* Produces point marks for each data point, optionally with size encoding
|
|
20
|
+
* for bubbles and a trend line overlay.
|
|
21
|
+
*/
|
|
22
|
+
export const scatterRenderer: ChartRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
23
|
+
const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
|
|
24
|
+
const marks: Mark[] = [...pointMarks];
|
|
25
|
+
|
|
26
|
+
// Add trend line if there are enough points
|
|
27
|
+
const trendLine = computeTrendLine(pointMarks);
|
|
28
|
+
if (trendLine) {
|
|
29
|
+
// Trend line goes behind points
|
|
30
|
+
marks.unshift(trendLine);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return marks;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Public exports
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
export { computeScatterMarks } from './compute';
|
|
41
|
+
export { computeTrendLine } from './trendline';
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trend line computation for scatter plots.
|
|
3
|
+
*
|
|
4
|
+
* Computes a simple linear regression (least squares) over the
|
|
5
|
+
* point marks and returns a LineMark representing the best-fit line.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { LineMark, MarkAria, PointMark } from '@opendata-ai/openchart-core';
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Constants
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const TRENDLINE_COLOR = '#666666';
|
|
15
|
+
const TRENDLINE_STROKE_WIDTH = 1.5;
|
|
16
|
+
const TRENDLINE_DASH = '6 4';
|
|
17
|
+
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Linear regression
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Compute slope and intercept for a simple linear regression.
|
|
24
|
+
* Returns null if there aren't enough points or variance is zero.
|
|
25
|
+
*/
|
|
26
|
+
function linearRegression(
|
|
27
|
+
points: { x: number; y: number }[],
|
|
28
|
+
): { slope: number; intercept: number } | null {
|
|
29
|
+
const n = points.length;
|
|
30
|
+
if (n < 2) return null;
|
|
31
|
+
|
|
32
|
+
let sumX = 0;
|
|
33
|
+
let sumY = 0;
|
|
34
|
+
let sumXY = 0;
|
|
35
|
+
let sumXX = 0;
|
|
36
|
+
|
|
37
|
+
for (const p of points) {
|
|
38
|
+
sumX += p.x;
|
|
39
|
+
sumY += p.y;
|
|
40
|
+
sumXY += p.x * p.y;
|
|
41
|
+
sumXX += p.x * p.x;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const denominator = n * sumXX - sumX * sumX;
|
|
45
|
+
if (denominator === 0) return null;
|
|
46
|
+
|
|
47
|
+
const slope = (n * sumXY - sumX * sumY) / denominator;
|
|
48
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
49
|
+
|
|
50
|
+
return { slope, intercept };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Public API
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Compute a trend line (linear regression) over scatter point marks.
|
|
59
|
+
*
|
|
60
|
+
* Returns a single LineMark spanning the x-range of the data points,
|
|
61
|
+
* rendered as a dashed line. Returns null if regression can't be computed.
|
|
62
|
+
*/
|
|
63
|
+
export function computeTrendLine(marks: PointMark[]): LineMark | null {
|
|
64
|
+
if (marks.length < 2) return null;
|
|
65
|
+
|
|
66
|
+
const points = marks.map((m) => ({ x: m.cx, y: m.cy }));
|
|
67
|
+
const result = linearRegression(points);
|
|
68
|
+
if (!result) return null;
|
|
69
|
+
|
|
70
|
+
const { slope, intercept } = result;
|
|
71
|
+
|
|
72
|
+
// Find x range from marks
|
|
73
|
+
let minX = Infinity;
|
|
74
|
+
let maxX = -Infinity;
|
|
75
|
+
for (const m of marks) {
|
|
76
|
+
if (m.cx < minX) minX = m.cx;
|
|
77
|
+
if (m.cx > maxX) maxX = m.cx;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Compute y values at the endpoints
|
|
81
|
+
const y1 = slope * minX + intercept;
|
|
82
|
+
const y2 = slope * maxX + intercept;
|
|
83
|
+
|
|
84
|
+
const aria: MarkAria = {
|
|
85
|
+
label: `Trend line: linear regression`,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
type: 'line',
|
|
90
|
+
points: [
|
|
91
|
+
{ x: minX, y: y1 },
|
|
92
|
+
{ x: maxX, y: y2 },
|
|
93
|
+
],
|
|
94
|
+
stroke: TRENDLINE_COLOR,
|
|
95
|
+
strokeWidth: TRENDLINE_STROKE_WIDTH,
|
|
96
|
+
strokeDasharray: TRENDLINE_DASH,
|
|
97
|
+
data: [],
|
|
98
|
+
aria,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for chart mark computation.
|
|
3
|
+
*
|
|
4
|
+
* Common helpers used across multiple chart types: scale value resolution,
|
|
5
|
+
* data grouping, color lookup, and shared constants.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { DataRow } from '@opendata-ai/openchart-core';
|
|
9
|
+
import type { ScaleBand, ScaleLinear, ScalePoint, ScaleTime } from 'd3-scale';
|
|
10
|
+
import type { D3Scale, ResolvedScales } from '../layout/scales';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
/** Default single-series color when no color encoding is present. */
|
|
17
|
+
export const DEFAULT_COLOR = '#1b7fa3';
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Scale helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Resolve a data value to a pixel position using a D3 scale.
|
|
25
|
+
*
|
|
26
|
+
* Handles time scales (parsing string dates), categorical scales
|
|
27
|
+
* (point, band, ordinal - passing string values directly), and
|
|
28
|
+
* linear/log scales (coercing to number). Returns null for values
|
|
29
|
+
* that can't be resolved (null, NaN, invalid dates, or values not
|
|
30
|
+
* in a categorical scale's domain).
|
|
31
|
+
*/
|
|
32
|
+
export function scaleValue(scale: D3Scale, scaleType: string, value: unknown): number | null {
|
|
33
|
+
if (value == null) return null;
|
|
34
|
+
|
|
35
|
+
if (scaleType === 'time') {
|
|
36
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
37
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
38
|
+
return (scale as ScaleTime<number, number>)(date);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Categorical scales: pass string values directly
|
|
42
|
+
if (scaleType === 'point' || scaleType === 'band' || scaleType === 'ordinal') {
|
|
43
|
+
const result = (scale as ScalePoint<string> | ScaleBand<string>)(String(value));
|
|
44
|
+
return result ?? null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
48
|
+
if (!Number.isFinite(num)) return null;
|
|
49
|
+
return (scale as ScaleLinear<number, number>)(num);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Data grouping
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Group data rows by a field value.
|
|
58
|
+
*
|
|
59
|
+
* If no field is provided, all rows are grouped under '__default__'.
|
|
60
|
+
* Returns a Map preserving insertion order.
|
|
61
|
+
*/
|
|
62
|
+
export function groupByField(data: DataRow[], field: string | undefined): Map<string, DataRow[]> {
|
|
63
|
+
const groups = new Map<string, DataRow[]>();
|
|
64
|
+
|
|
65
|
+
if (!field) {
|
|
66
|
+
groups.set('__default__', data);
|
|
67
|
+
return groups;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const row of data) {
|
|
71
|
+
const key = String(row[field] ?? '__default__');
|
|
72
|
+
const existing = groups.get(key);
|
|
73
|
+
if (existing) {
|
|
74
|
+
existing.push(row);
|
|
75
|
+
} else {
|
|
76
|
+
groups.set(key, [row]);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return groups;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Color helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get the color for a series/category from the resolved color scale.
|
|
89
|
+
*
|
|
90
|
+
* For single-series charts (key === '__default__'), uses the theme's
|
|
91
|
+
* first categorical color via scales.defaultColor.
|
|
92
|
+
*/
|
|
93
|
+
export function getColor(
|
|
94
|
+
scales: ResolvedScales,
|
|
95
|
+
key: string,
|
|
96
|
+
_index?: number,
|
|
97
|
+
fallback: string = DEFAULT_COLOR,
|
|
98
|
+
): string {
|
|
99
|
+
if (scales.color && key !== '__default__') {
|
|
100
|
+
const colorScale = scales.color.scale as (v: string) => string;
|
|
101
|
+
return colorScale(key);
|
|
102
|
+
}
|
|
103
|
+
return scales.defaultColor ?? fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get color from a sequential (quantitative) color scale.
|
|
108
|
+
* Maps a numeric value to a color via linear interpolation.
|
|
109
|
+
*/
|
|
110
|
+
export function getSequentialColor(
|
|
111
|
+
scales: ResolvedScales,
|
|
112
|
+
value: number,
|
|
113
|
+
fallback: string = DEFAULT_COLOR,
|
|
114
|
+
): string {
|
|
115
|
+
if (scales.color?.type === 'sequential') {
|
|
116
|
+
const colorScale = scales.color.scale as unknown as (v: number) => string;
|
|
117
|
+
return colorScale(value);
|
|
118
|
+
}
|
|
119
|
+
return scales.defaultColor ?? fallback;
|
|
120
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main compile API: the public entry points for the engine.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline for charts:
|
|
5
|
+
* validate spec -> normalize -> resolve theme -> dark mode adapt ->
|
|
6
|
+
* compute legend -> compute dimensions (with legend space) ->
|
|
7
|
+
* compute scales -> compute axes -> compute gridlines ->
|
|
8
|
+
* get chart renderer -> compute marks -> compute a11y -> return ChartLayout
|
|
9
|
+
*
|
|
10
|
+
* Table compiler handles full data pipeline (sort, search, pagination, visual enhancements).
|
|
11
|
+
* Graph compiler is a stub for future implementation.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
ChartLayout,
|
|
16
|
+
CompileOptions,
|
|
17
|
+
CompileTableOptions,
|
|
18
|
+
Mark,
|
|
19
|
+
PointMark,
|
|
20
|
+
Rect,
|
|
21
|
+
RectMark,
|
|
22
|
+
ResolvedAnnotation,
|
|
23
|
+
ResolvedTheme,
|
|
24
|
+
TableLayout,
|
|
25
|
+
} from '@opendata-ai/openchart-core';
|
|
26
|
+
import {
|
|
27
|
+
adaptTheme,
|
|
28
|
+
generateAltText,
|
|
29
|
+
generateDataTable,
|
|
30
|
+
getBreakpoint,
|
|
31
|
+
getLayoutStrategy,
|
|
32
|
+
resolveTheme,
|
|
33
|
+
} from '@opendata-ai/openchart-core';
|
|
34
|
+
import { computeAnnotations } from './annotations/compute';
|
|
35
|
+
import { barRenderer } from './charts/bar';
|
|
36
|
+
import { columnRenderer } from './charts/column';
|
|
37
|
+
import { dotRenderer } from './charts/dot';
|
|
38
|
+
import { areaRenderer, lineRenderer } from './charts/line';
|
|
39
|
+
import { donutRenderer, pieRenderer } from './charts/pie';
|
|
40
|
+
import { type ChartRenderer, getChartRenderer, registerChartRenderer } from './charts/registry';
|
|
41
|
+
import { scatterRenderer } from './charts/scatter';
|
|
42
|
+
import { compile as compileSpec } from './compiler/index';
|
|
43
|
+
|
|
44
|
+
// Register all built-in chart renderers. Explicit imports ensure bundlers
|
|
45
|
+
// cannot tree-shake the registrations away (bare side-effect imports are
|
|
46
|
+
// treated as dead code by esbuild).
|
|
47
|
+
const builtinRenderers: Record<string, ChartRenderer> = {
|
|
48
|
+
line: lineRenderer,
|
|
49
|
+
area: areaRenderer,
|
|
50
|
+
bar: barRenderer,
|
|
51
|
+
column: columnRenderer,
|
|
52
|
+
scatter: scatterRenderer,
|
|
53
|
+
pie: pieRenderer,
|
|
54
|
+
donut: donutRenderer,
|
|
55
|
+
dot: dotRenderer,
|
|
56
|
+
};
|
|
57
|
+
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
58
|
+
registerChartRenderer(type, renderer);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
import type { NormalizedChartSpec, NormalizedTableSpec } from './compiler/types';
|
|
62
|
+
import { compileGraph as compileGraphImpl } from './graphs/compile-graph';
|
|
63
|
+
import type { GraphCompilation } from './graphs/types';
|
|
64
|
+
import { computeAxes } from './layout/axes';
|
|
65
|
+
import { computeDimensions } from './layout/dimensions';
|
|
66
|
+
import { computeGridlines } from './layout/gridlines';
|
|
67
|
+
import { computeScales, type ResolvedScales } from './layout/scales';
|
|
68
|
+
import { computeLegend } from './legend/compute';
|
|
69
|
+
import { compileTableLayout } from './tables/compile-table';
|
|
70
|
+
import { computeTooltipDescriptors } from './tooltips/compute';
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Mark obstacles for annotation collision avoidance
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Compute per-row bounding rects for band-scale charts (dot, bar).
|
|
78
|
+
* Each obstacle covers the full band height and x-range of marks in that row,
|
|
79
|
+
* giving the annotation nudge system awareness of data marks.
|
|
80
|
+
*/
|
|
81
|
+
function computeRowObstacles(marks: Mark[], scales: ResolvedScales): Rect[] {
|
|
82
|
+
if (!scales.y || scales.y.type !== 'band') return [];
|
|
83
|
+
|
|
84
|
+
// Group marks by their y-center (rounded), compute x-extent per group
|
|
85
|
+
const rows = new Map<number, { minX: number; maxX: number; bandY: number }>();
|
|
86
|
+
|
|
87
|
+
for (const mark of marks) {
|
|
88
|
+
let cy: number;
|
|
89
|
+
let left: number;
|
|
90
|
+
let right: number;
|
|
91
|
+
|
|
92
|
+
if (mark.type === 'point') {
|
|
93
|
+
const pm = mark as PointMark;
|
|
94
|
+
cy = pm.cy;
|
|
95
|
+
left = pm.cx - pm.r;
|
|
96
|
+
right = pm.cx + pm.r;
|
|
97
|
+
} else if (mark.type === 'rect') {
|
|
98
|
+
const rm = mark as RectMark;
|
|
99
|
+
cy = rm.y + rm.height / 2;
|
|
100
|
+
left = rm.x;
|
|
101
|
+
right = rm.x + rm.width;
|
|
102
|
+
} else {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Round cy to group marks on the same band
|
|
107
|
+
const key = Math.round(cy);
|
|
108
|
+
const existing = rows.get(key);
|
|
109
|
+
if (existing) {
|
|
110
|
+
existing.minX = Math.min(existing.minX, left);
|
|
111
|
+
existing.maxX = Math.max(existing.maxX, right);
|
|
112
|
+
} else {
|
|
113
|
+
rows.set(key, { minX: left, maxX: right, bandY: cy });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get bandwidth from the band scale
|
|
118
|
+
const bandScale = scales.y.scale as { bandwidth?: () => number };
|
|
119
|
+
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
120
|
+
if (bandwidth === 0) return [];
|
|
121
|
+
|
|
122
|
+
const obstacles: Rect[] = [];
|
|
123
|
+
for (const { minX, maxX, bandY } of rows.values()) {
|
|
124
|
+
obstacles.push({
|
|
125
|
+
x: minX,
|
|
126
|
+
y: bandY - bandwidth / 2,
|
|
127
|
+
width: maxX - minX,
|
|
128
|
+
height: bandwidth,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return obstacles;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Chart compilation
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compile a chart spec into a ChartLayout.
|
|
141
|
+
*
|
|
142
|
+
* This is the main engine entry point. Takes a raw spec (any shape,
|
|
143
|
+
* validated at runtime) and compile options, produces a fully resolved
|
|
144
|
+
* ChartLayout with positions, colors, and marks ready for rendering.
|
|
145
|
+
*
|
|
146
|
+
* @param spec - Raw chart spec (validated and normalized internally).
|
|
147
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
148
|
+
* @returns ChartLayout with all computed positions.
|
|
149
|
+
* @throws Error if spec is invalid or not a chart type.
|
|
150
|
+
*/
|
|
151
|
+
export function compileChart(spec: unknown, options: CompileOptions): ChartLayout {
|
|
152
|
+
// Validate + normalize
|
|
153
|
+
const { spec: normalized } = compileSpec(spec);
|
|
154
|
+
|
|
155
|
+
if (normalized.type === 'table') {
|
|
156
|
+
throw new Error('compileChart received a table spec. Use compileTable instead.');
|
|
157
|
+
}
|
|
158
|
+
if (normalized.type === 'graph') {
|
|
159
|
+
throw new Error('compileChart received a graph spec. Use compileGraph instead.');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const chartSpec = normalized as NormalizedChartSpec;
|
|
163
|
+
|
|
164
|
+
// Resolve theme: merge spec-level theme with options-level overrides
|
|
165
|
+
const mergedThemeConfig = options.theme
|
|
166
|
+
? { ...chartSpec.theme, ...options.theme }
|
|
167
|
+
: chartSpec.theme;
|
|
168
|
+
let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
169
|
+
if (options.darkMode) {
|
|
170
|
+
theme = adaptTheme(theme);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Responsive strategy
|
|
174
|
+
const breakpoint = getBreakpoint(options.width);
|
|
175
|
+
const strategy = getLayoutStrategy(breakpoint);
|
|
176
|
+
|
|
177
|
+
// Compute legend first (needs to reserve space)
|
|
178
|
+
const preliminaryArea: Rect = {
|
|
179
|
+
x: 0,
|
|
180
|
+
y: 0,
|
|
181
|
+
width: options.width,
|
|
182
|
+
height: options.height,
|
|
183
|
+
};
|
|
184
|
+
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
185
|
+
|
|
186
|
+
// Compute dimensions (accounts for chrome + legend)
|
|
187
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme);
|
|
188
|
+
const chartArea = dims.chartArea;
|
|
189
|
+
|
|
190
|
+
// Recompute legend bounds relative to actual chart area.
|
|
191
|
+
// chartArea was shrunk to exclude legend space, so expand it back to include
|
|
192
|
+
// the reserved margin. This way computeLegend positions the legend outside
|
|
193
|
+
// the data area (in the margin) instead of overlapping data marks.
|
|
194
|
+
const legendArea: Rect = { ...chartArea };
|
|
195
|
+
if (legendLayout.entries.length > 0) {
|
|
196
|
+
switch (legendLayout.position) {
|
|
197
|
+
case 'top':
|
|
198
|
+
legendArea.y -= legendLayout.bounds.height + 4;
|
|
199
|
+
legendArea.height += legendLayout.bounds.height + 4;
|
|
200
|
+
break;
|
|
201
|
+
case 'bottom':
|
|
202
|
+
legendArea.height += legendLayout.bounds.height + 4;
|
|
203
|
+
break;
|
|
204
|
+
case 'right':
|
|
205
|
+
case 'bottom-right':
|
|
206
|
+
legendArea.width += legendLayout.bounds.width + 8;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
|
|
211
|
+
|
|
212
|
+
// Compute scales
|
|
213
|
+
const scales = computeScales(chartSpec, chartArea, chartSpec.data);
|
|
214
|
+
|
|
215
|
+
// Update color scale to use theme palette
|
|
216
|
+
if (scales.color) {
|
|
217
|
+
if (scales.color.type === 'sequential') {
|
|
218
|
+
// Sequential: use first sequential palette (or fall back to categorical endpoints)
|
|
219
|
+
const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
220
|
+
(scales.color.scale as unknown as import('d3-scale').ScaleLinear<string, string>).range([
|
|
221
|
+
seqStops[0],
|
|
222
|
+
seqStops[seqStops.length - 1],
|
|
223
|
+
]);
|
|
224
|
+
} else {
|
|
225
|
+
(scales.color.scale as import('d3-scale').ScaleOrdinal<string, string>).range(
|
|
226
|
+
theme.colors.categorical,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Set default color for single-series charts (no color encoding)
|
|
232
|
+
scales.defaultColor = theme.colors.categorical[0];
|
|
233
|
+
|
|
234
|
+
// Pie/donut charts don't use axes or gridlines
|
|
235
|
+
const isRadial = chartSpec.type === 'pie' || chartSpec.type === 'donut';
|
|
236
|
+
|
|
237
|
+
// Compute axes (skip for radial charts)
|
|
238
|
+
const axes = isRadial
|
|
239
|
+
? { x: undefined, y: undefined }
|
|
240
|
+
: computeAxes(scales, chartArea, strategy, theme);
|
|
241
|
+
|
|
242
|
+
// Compute gridlines (stored in axes, used by adapters via axes.y.gridlines)
|
|
243
|
+
if (!isRadial) {
|
|
244
|
+
computeGridlines(axes, chartArea);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Get chart renderer and compute marks
|
|
248
|
+
const renderer = getChartRenderer(chartSpec.type);
|
|
249
|
+
const marks: Mark[] = renderer ? renderer(chartSpec, scales, chartArea, strategy, theme) : [];
|
|
250
|
+
|
|
251
|
+
// Compute annotations from spec, passing legend + mark bounds as obstacles for collision avoidance
|
|
252
|
+
const obstacles: Rect[] = [];
|
|
253
|
+
if (finalLegend.bounds.width > 0) {
|
|
254
|
+
obstacles.push(finalLegend.bounds);
|
|
255
|
+
}
|
|
256
|
+
obstacles.push(...computeRowObstacles(marks, scales));
|
|
257
|
+
const annotations: ResolvedAnnotation[] = computeAnnotations(
|
|
258
|
+
chartSpec,
|
|
259
|
+
scales,
|
|
260
|
+
chartArea,
|
|
261
|
+
strategy,
|
|
262
|
+
theme.isDark,
|
|
263
|
+
obstacles,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Compute tooltip descriptors from marks and encoding
|
|
267
|
+
const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
|
|
268
|
+
|
|
269
|
+
// Compute accessibility
|
|
270
|
+
const altText = generateAltText(
|
|
271
|
+
{
|
|
272
|
+
type: chartSpec.type,
|
|
273
|
+
data: chartSpec.data,
|
|
274
|
+
encoding: chartSpec.encoding,
|
|
275
|
+
chrome: chartSpec.chrome,
|
|
276
|
+
},
|
|
277
|
+
chartSpec.data,
|
|
278
|
+
);
|
|
279
|
+
const dataTableFallback = generateDataTable(
|
|
280
|
+
{
|
|
281
|
+
type: chartSpec.type,
|
|
282
|
+
data: chartSpec.data,
|
|
283
|
+
encoding: chartSpec.encoding,
|
|
284
|
+
},
|
|
285
|
+
chartSpec.data,
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
area: chartArea,
|
|
290
|
+
chrome: dims.chrome,
|
|
291
|
+
axes: {
|
|
292
|
+
x: axes.x,
|
|
293
|
+
y: axes.y,
|
|
294
|
+
},
|
|
295
|
+
marks,
|
|
296
|
+
annotations,
|
|
297
|
+
legend: finalLegend,
|
|
298
|
+
tooltipDescriptors,
|
|
299
|
+
a11y: {
|
|
300
|
+
altText,
|
|
301
|
+
dataTableFallback,
|
|
302
|
+
role: 'img',
|
|
303
|
+
keyboardNavigable: marks.length > 0,
|
|
304
|
+
},
|
|
305
|
+
theme,
|
|
306
|
+
dimensions: {
|
|
307
|
+
width: options.width,
|
|
308
|
+
height: options.height,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Table compilation
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Compile a table spec into a TableLayout.
|
|
319
|
+
*
|
|
320
|
+
* Validates and normalizes the spec, resolves the theme, then delegates
|
|
321
|
+
* to compileTableLayout for the full pipeline: column resolution, search,
|
|
322
|
+
* sort, pagination, cell formatting, and visual enhancements.
|
|
323
|
+
*
|
|
324
|
+
* @param spec - Raw table spec.
|
|
325
|
+
* @param options - Compile options with sort, search, pagination state.
|
|
326
|
+
* @returns Fully resolved TableLayout.
|
|
327
|
+
*/
|
|
328
|
+
export function compileTable(spec: unknown, options: CompileTableOptions): TableLayout {
|
|
329
|
+
const { spec: normalized } = compileSpec(spec);
|
|
330
|
+
|
|
331
|
+
if (normalized.type !== 'table') {
|
|
332
|
+
throw new Error(`compileTable received a ${normalized.type} spec. Use compileChart instead.`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const tableSpec = normalized as NormalizedTableSpec;
|
|
336
|
+
|
|
337
|
+
// Resolve theme: merge spec-level theme with options-level overrides
|
|
338
|
+
const mergedThemeConfig = options.theme
|
|
339
|
+
? { ...tableSpec.theme, ...options.theme }
|
|
340
|
+
: tableSpec.theme;
|
|
341
|
+
let theme: ResolvedTheme = resolveTheme(mergedThemeConfig);
|
|
342
|
+
if (options.darkMode) {
|
|
343
|
+
theme = adaptTheme(theme);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return compileTableLayout(tableSpec, options, theme);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Graph compilation
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Compile a graph spec into a GraphCompilation.
|
|
355
|
+
*
|
|
356
|
+
* The graph pipeline resolves visual properties (size, color, stroke) for
|
|
357
|
+
* nodes and edges, assigns communities, and builds legend/tooltip/a11y data.
|
|
358
|
+
* Unlike charts, the output does NOT include x/y positions since the force
|
|
359
|
+
* simulation in the adapter handles layout at runtime.
|
|
360
|
+
*
|
|
361
|
+
* @param spec - Raw graph spec (validated and normalized internally).
|
|
362
|
+
* @param options - Compile options (width, height, theme, darkMode).
|
|
363
|
+
* @returns GraphCompilation with resolved visual properties and simulation config.
|
|
364
|
+
* @throws Error if spec is invalid or not a graph type.
|
|
365
|
+
*/
|
|
366
|
+
export function compileGraph(spec: unknown, options: CompileOptions): GraphCompilation {
|
|
367
|
+
return compileGraphImpl(spec, options);
|
|
368
|
+
}
|