@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,4227 @@
|
|
|
1
|
+
// src/compile.ts
|
|
2
|
+
import {
|
|
3
|
+
adaptTheme as adaptTheme2,
|
|
4
|
+
generateAltText,
|
|
5
|
+
generateDataTable,
|
|
6
|
+
getBreakpoint,
|
|
7
|
+
getLayoutStrategy,
|
|
8
|
+
resolveTheme as resolveTheme2
|
|
9
|
+
} from "@opendata-ai/openchart-core";
|
|
10
|
+
|
|
11
|
+
// src/annotations/compute.ts
|
|
12
|
+
import { estimateTextWidth } from "@opendata-ai/openchart-core";
|
|
13
|
+
var DEFAULT_ANNOTATION_FONT_SIZE = 12;
|
|
14
|
+
var DEFAULT_ANNOTATION_FONT_WEIGHT = 400;
|
|
15
|
+
var DEFAULT_RANGE_FILL = "#f0c040";
|
|
16
|
+
var DEFAULT_RANGE_OPACITY = 0.15;
|
|
17
|
+
var DEFAULT_REFLINE_DASH = "4 3";
|
|
18
|
+
var LIGHT_TEXT_FILL = "#333333";
|
|
19
|
+
var DARK_TEXT_FILL = "#d1d5db";
|
|
20
|
+
var LIGHT_REFLINE_STROKE = "#888888";
|
|
21
|
+
var DARK_REFLINE_STROKE = "#9ca3af";
|
|
22
|
+
var ANCHOR_OFFSET = 8;
|
|
23
|
+
function resolvePosition(value, scale) {
|
|
24
|
+
if (!scale) return null;
|
|
25
|
+
const s = scale.scale;
|
|
26
|
+
const type = scale.type;
|
|
27
|
+
if (type === "time") {
|
|
28
|
+
const date = new Date(String(value));
|
|
29
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
30
|
+
return s(date);
|
|
31
|
+
}
|
|
32
|
+
if (type === "linear" || type === "log") {
|
|
33
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
34
|
+
if (!Number.isFinite(num)) return null;
|
|
35
|
+
return s(num);
|
|
36
|
+
}
|
|
37
|
+
if (type === "band") {
|
|
38
|
+
const bandScale = s;
|
|
39
|
+
const pos = bandScale(String(value));
|
|
40
|
+
if (pos === void 0) return null;
|
|
41
|
+
return pos + (bandScale.bandwidth?.() ?? 0) / 2;
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return s(String(value));
|
|
45
|
+
} catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function makeAnnotationLabelStyle(fontSize, fontWeight, fill, isDark) {
|
|
50
|
+
const defaultFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
51
|
+
return {
|
|
52
|
+
fontFamily: "Inter, system-ui, sans-serif",
|
|
53
|
+
fontSize: fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE,
|
|
54
|
+
fontWeight: fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT,
|
|
55
|
+
fill: fill ?? defaultFill,
|
|
56
|
+
lineHeight: 1.3,
|
|
57
|
+
textAnchor: "start"
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function computeAnchorOffset(anchor, _px, py, chartArea) {
|
|
61
|
+
if (!anchor || anchor === "auto") {
|
|
62
|
+
const isUpperHalf = py < chartArea.y + chartArea.height / 2;
|
|
63
|
+
return isUpperHalf ? { dx: ANCHOR_OFFSET, dy: ANCHOR_OFFSET } : { dx: ANCHOR_OFFSET, dy: -ANCHOR_OFFSET };
|
|
64
|
+
}
|
|
65
|
+
switch (anchor) {
|
|
66
|
+
case "top":
|
|
67
|
+
return { dx: 0, dy: -ANCHOR_OFFSET };
|
|
68
|
+
case "bottom":
|
|
69
|
+
return { dx: 0, dy: ANCHOR_OFFSET };
|
|
70
|
+
case "left":
|
|
71
|
+
return { dx: -ANCHOR_OFFSET, dy: 0 };
|
|
72
|
+
case "right":
|
|
73
|
+
return { dx: ANCHOR_OFFSET, dy: 0 };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function applyOffset(base, offset) {
|
|
77
|
+
if (!offset) return base;
|
|
78
|
+
return {
|
|
79
|
+
dx: base.dx + (offset.dx ?? 0),
|
|
80
|
+
dy: base.dy + (offset.dy ?? 0)
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function resolveTextAnnotation(annotation, scales, chartArea, isDark) {
|
|
84
|
+
const px = resolvePosition(annotation.x, scales.x);
|
|
85
|
+
const py = resolvePosition(annotation.y, scales.y);
|
|
86
|
+
if (px === null || py === null) return null;
|
|
87
|
+
const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL;
|
|
88
|
+
const labelStyle = makeAnnotationLabelStyle(
|
|
89
|
+
annotation.fontSize,
|
|
90
|
+
annotation.fontWeight,
|
|
91
|
+
annotation.fill ?? defaultTextFill,
|
|
92
|
+
isDark
|
|
93
|
+
);
|
|
94
|
+
const anchorDelta = computeAnchorOffset(annotation.anchor, px, py, chartArea);
|
|
95
|
+
const finalDelta = applyOffset(anchorDelta, annotation.offset);
|
|
96
|
+
const labelX = px + finalDelta.dx;
|
|
97
|
+
const labelY = py + finalDelta.dy;
|
|
98
|
+
const showConnector = annotation.connector !== false;
|
|
99
|
+
const connectorStyle = annotation.connector === "curve" ? "curve" : "straight";
|
|
100
|
+
const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
101
|
+
const fontWeight = annotation.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
102
|
+
const lines = annotation.text.split("\n");
|
|
103
|
+
const lineHeight = 1.3;
|
|
104
|
+
let connectorFromX;
|
|
105
|
+
if (connectorStyle === "curve") {
|
|
106
|
+
connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight);
|
|
107
|
+
} else if (lines.length > 1) {
|
|
108
|
+
connectorFromX = labelX;
|
|
109
|
+
} else {
|
|
110
|
+
connectorFromX = labelX + estimateTextWidth(annotation.text, fontSize, fontWeight) / 2;
|
|
111
|
+
}
|
|
112
|
+
const connectorFromY = labelY + (lines.length - 1) * fontSize * lineHeight + fontSize * 0.3;
|
|
113
|
+
const baseFrom = { x: connectorFromX, y: connectorFromY };
|
|
114
|
+
const baseTo = { x: px, y: py };
|
|
115
|
+
const adjustedFrom = {
|
|
116
|
+
x: baseFrom.x + (annotation.connectorOffset?.from?.dx ?? 0),
|
|
117
|
+
y: baseFrom.y + (annotation.connectorOffset?.from?.dy ?? 0)
|
|
118
|
+
};
|
|
119
|
+
const adjustedToRaw = {
|
|
120
|
+
x: baseTo.x + (annotation.connectorOffset?.to?.dx ?? 0),
|
|
121
|
+
y: baseTo.y + (annotation.connectorOffset?.to?.dy ?? 0)
|
|
122
|
+
};
|
|
123
|
+
const GAP = 4;
|
|
124
|
+
const cdx = adjustedToRaw.x - adjustedFrom.x;
|
|
125
|
+
const cdy = adjustedToRaw.y - adjustedFrom.y;
|
|
126
|
+
const dist = Math.sqrt(cdx * cdx + cdy * cdy);
|
|
127
|
+
const adjustedTo = dist > GAP * 2 ? { x: adjustedToRaw.x - cdx / dist * GAP, y: adjustedToRaw.y - cdy / dist * GAP } : adjustedToRaw;
|
|
128
|
+
const label = {
|
|
129
|
+
text: annotation.text,
|
|
130
|
+
x: labelX,
|
|
131
|
+
y: labelY,
|
|
132
|
+
style: labelStyle,
|
|
133
|
+
visible: true,
|
|
134
|
+
connector: showConnector ? {
|
|
135
|
+
from: adjustedFrom,
|
|
136
|
+
to: adjustedTo,
|
|
137
|
+
stroke: annotation.stroke ?? "#999999",
|
|
138
|
+
style: connectorStyle
|
|
139
|
+
} : void 0,
|
|
140
|
+
background: annotation.background
|
|
141
|
+
};
|
|
142
|
+
return {
|
|
143
|
+
type: "text",
|
|
144
|
+
label,
|
|
145
|
+
stroke: annotation.stroke,
|
|
146
|
+
fill: annotation.fill,
|
|
147
|
+
opacity: annotation.opacity,
|
|
148
|
+
zIndex: annotation.zIndex
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function resolveRangeAnnotation(annotation, scales, chartArea, isDark) {
|
|
152
|
+
let x = chartArea.x;
|
|
153
|
+
let y = chartArea.y;
|
|
154
|
+
let width = chartArea.width;
|
|
155
|
+
let height = chartArea.height;
|
|
156
|
+
if (annotation.x1 !== void 0 && annotation.x2 !== void 0) {
|
|
157
|
+
const x1px = resolvePosition(annotation.x1, scales.x);
|
|
158
|
+
const x2px = resolvePosition(annotation.x2, scales.x);
|
|
159
|
+
if (x1px === null || x2px === null) return null;
|
|
160
|
+
x = Math.min(x1px, x2px);
|
|
161
|
+
width = Math.abs(x2px - x1px);
|
|
162
|
+
}
|
|
163
|
+
if (annotation.y1 !== void 0 && annotation.y2 !== void 0) {
|
|
164
|
+
const y1px = resolvePosition(annotation.y1, scales.y);
|
|
165
|
+
const y2px = resolvePosition(annotation.y2, scales.y);
|
|
166
|
+
if (y1px === null || y2px === null) return null;
|
|
167
|
+
y = Math.min(y1px, y2px);
|
|
168
|
+
height = Math.abs(y2px - y1px);
|
|
169
|
+
}
|
|
170
|
+
const rect = { x, y, width, height };
|
|
171
|
+
let label;
|
|
172
|
+
if (annotation.label) {
|
|
173
|
+
const baseDx = 4;
|
|
174
|
+
const baseDy = 14;
|
|
175
|
+
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
176
|
+
label = {
|
|
177
|
+
text: annotation.label,
|
|
178
|
+
x: x + labelDelta.dx,
|
|
179
|
+
y: y + labelDelta.dy,
|
|
180
|
+
style: makeAnnotationLabelStyle(11, 500, void 0, isDark),
|
|
181
|
+
visible: true
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const defaultOpacity = isDark ? 0.2 : DEFAULT_RANGE_OPACITY;
|
|
185
|
+
return {
|
|
186
|
+
type: "range",
|
|
187
|
+
rect,
|
|
188
|
+
label,
|
|
189
|
+
fill: annotation.fill ?? DEFAULT_RANGE_FILL,
|
|
190
|
+
opacity: annotation.opacity ?? defaultOpacity,
|
|
191
|
+
stroke: annotation.stroke,
|
|
192
|
+
zIndex: annotation.zIndex
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function resolveRefLineAnnotation(annotation, scales, chartArea, isDark) {
|
|
196
|
+
let start;
|
|
197
|
+
let end;
|
|
198
|
+
if (annotation.y !== void 0) {
|
|
199
|
+
const yPx = resolvePosition(annotation.y, scales.y);
|
|
200
|
+
if (yPx === null) return null;
|
|
201
|
+
start = { x: chartArea.x, y: yPx };
|
|
202
|
+
end = { x: chartArea.x + chartArea.width, y: yPx };
|
|
203
|
+
} else if (annotation.x !== void 0) {
|
|
204
|
+
const xPx = resolvePosition(annotation.x, scales.x);
|
|
205
|
+
if (xPx === null) return null;
|
|
206
|
+
start = { x: xPx, y: chartArea.y };
|
|
207
|
+
end = { x: xPx, y: chartArea.y + chartArea.height };
|
|
208
|
+
} else {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
let strokeDasharray;
|
|
212
|
+
if (annotation.style === "dashed" || annotation.style === void 0) {
|
|
213
|
+
strokeDasharray = DEFAULT_REFLINE_DASH;
|
|
214
|
+
} else if (annotation.style === "dotted") {
|
|
215
|
+
strokeDasharray = "2 2";
|
|
216
|
+
}
|
|
217
|
+
let label;
|
|
218
|
+
if (annotation.label) {
|
|
219
|
+
const isHorizontal = annotation.y !== void 0;
|
|
220
|
+
const baseDx = isHorizontal ? -4 : 4;
|
|
221
|
+
const baseDy = -4;
|
|
222
|
+
const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset);
|
|
223
|
+
const defaultStroke2 = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
224
|
+
const style = makeAnnotationLabelStyle(11, 400, annotation.stroke ?? defaultStroke2, isDark);
|
|
225
|
+
if (isHorizontal) {
|
|
226
|
+
style.textAnchor = "end";
|
|
227
|
+
}
|
|
228
|
+
label = {
|
|
229
|
+
text: annotation.label,
|
|
230
|
+
x: (isHorizontal ? end.x : start.x) + labelDelta.dx,
|
|
231
|
+
y: (isHorizontal ? end.y : start.y) + labelDelta.dy,
|
|
232
|
+
style,
|
|
233
|
+
visible: true
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE;
|
|
237
|
+
return {
|
|
238
|
+
type: "refline",
|
|
239
|
+
line: { start, end },
|
|
240
|
+
label,
|
|
241
|
+
stroke: annotation.stroke ?? defaultStroke,
|
|
242
|
+
strokeDasharray,
|
|
243
|
+
strokeWidth: annotation.strokeWidth ?? 1,
|
|
244
|
+
zIndex: annotation.zIndex
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
function estimateLabelBounds(label) {
|
|
248
|
+
const lines = label.text.split("\n");
|
|
249
|
+
const fontSize = label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE;
|
|
250
|
+
const fontWeight = label.style.fontWeight ?? DEFAULT_ANNOTATION_FONT_WEIGHT;
|
|
251
|
+
const lineHeight = label.style.lineHeight ?? 1.3;
|
|
252
|
+
const maxWidth = Math.max(...lines.map((line3) => estimateTextWidth(line3, fontSize, fontWeight)));
|
|
253
|
+
const totalHeight = lines.length * fontSize * lineHeight;
|
|
254
|
+
const isMultiLine = lines.length > 1;
|
|
255
|
+
const anchorX = isMultiLine ? label.x - maxWidth / 2 : label.x;
|
|
256
|
+
return {
|
|
257
|
+
x: anchorX,
|
|
258
|
+
y: label.y - fontSize,
|
|
259
|
+
width: maxWidth,
|
|
260
|
+
height: totalHeight
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function rectsOverlap(a, b) {
|
|
264
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
265
|
+
}
|
|
266
|
+
var NUDGE_PADDING = 6;
|
|
267
|
+
function nudgeAnnotationFromObstacles(annotation, originalAnnotation, scales, chartArea, obstacles) {
|
|
268
|
+
if (annotation.type !== "text" || !annotation.label) return false;
|
|
269
|
+
const labelBounds = estimateLabelBounds(annotation.label);
|
|
270
|
+
const collidingObs = obstacles.filter(
|
|
271
|
+
(obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(labelBounds, obs)
|
|
272
|
+
);
|
|
273
|
+
if (collidingObs.length === 0) return false;
|
|
274
|
+
const px = resolvePosition(originalAnnotation.x, scales.x);
|
|
275
|
+
const py = resolvePosition(originalAnnotation.y, scales.y);
|
|
276
|
+
if (px === null || py === null) return false;
|
|
277
|
+
const candidates = [];
|
|
278
|
+
const fontSize = labelBounds.height / Math.max(1, annotation.label.text.split("\n").length);
|
|
279
|
+
for (const obs of collidingObs) {
|
|
280
|
+
const currentLabelTop = labelBounds.y;
|
|
281
|
+
const targetLabelTop = obs.y + obs.height + NUDGE_PADDING;
|
|
282
|
+
const belowDy = targetLabelTop - currentLabelTop;
|
|
283
|
+
candidates.push({ dx: 0, dy: belowDy, distance: Math.abs(belowDy) });
|
|
284
|
+
const currentLabelBottom = labelBounds.y + labelBounds.height;
|
|
285
|
+
const targetLabelBottom = obs.y - NUDGE_PADDING;
|
|
286
|
+
const aboveDy = targetLabelBottom - currentLabelBottom;
|
|
287
|
+
candidates.push({ dx: 0, dy: aboveDy, distance: Math.abs(aboveDy) });
|
|
288
|
+
const currentLabelRight = labelBounds.x + labelBounds.width;
|
|
289
|
+
const targetLabelRight = obs.x - NUDGE_PADDING;
|
|
290
|
+
const leftDx = targetLabelRight - currentLabelRight;
|
|
291
|
+
candidates.push({ dx: leftDx, dy: 0, distance: Math.abs(leftDx) });
|
|
292
|
+
const currentLabelLeft = labelBounds.x;
|
|
293
|
+
const targetLabelLeft = obs.x + obs.width + NUDGE_PADDING;
|
|
294
|
+
const rightDx = targetLabelLeft - currentLabelLeft;
|
|
295
|
+
candidates.push({ dx: rightDx, dy: 0, distance: Math.abs(rightDx) });
|
|
296
|
+
}
|
|
297
|
+
candidates.sort((a, b) => a.distance - b.distance);
|
|
298
|
+
for (const { dx, dy } of candidates) {
|
|
299
|
+
const candidateLabel = {
|
|
300
|
+
...annotation.label,
|
|
301
|
+
x: annotation.label.x + dx,
|
|
302
|
+
y: annotation.label.y + dy,
|
|
303
|
+
connector: annotation.label.connector ? {
|
|
304
|
+
...annotation.label.connector,
|
|
305
|
+
from: {
|
|
306
|
+
x: annotation.label.connector.from.x + dx,
|
|
307
|
+
y: annotation.label.connector.from.y + dy
|
|
308
|
+
}
|
|
309
|
+
} : void 0
|
|
310
|
+
};
|
|
311
|
+
const candidateBounds = estimateLabelBounds(candidateLabel);
|
|
312
|
+
const stillCollides = obstacles.some(
|
|
313
|
+
(obs) => obs.width > 0 && obs.height > 0 && rectsOverlap(candidateBounds, obs)
|
|
314
|
+
);
|
|
315
|
+
if (stillCollides) continue;
|
|
316
|
+
const labelCenterX = candidateBounds.x + candidateBounds.width / 2;
|
|
317
|
+
const labelCenterY = candidateBounds.y + candidateBounds.height / 2;
|
|
318
|
+
const inBounds = labelCenterX >= chartArea.x && labelCenterX <= chartArea.x + chartArea.width + 100 && labelCenterY >= chartArea.y - fontSize && labelCenterY <= chartArea.y + chartArea.height + fontSize;
|
|
319
|
+
if (inBounds) {
|
|
320
|
+
if (candidateLabel.connector && dx === 0 && dy !== 0) {
|
|
321
|
+
candidateLabel.connector = {
|
|
322
|
+
...candidateLabel.connector,
|
|
323
|
+
style: "caret"
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
annotation.label = candidateLabel;
|
|
327
|
+
return true;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
function computeAnnotations(spec, scales, chartArea, strategy, isDark = false, obstacles = []) {
|
|
333
|
+
if (strategy.annotationPosition === "tooltip-only") {
|
|
334
|
+
return [];
|
|
335
|
+
}
|
|
336
|
+
const annotations = [];
|
|
337
|
+
for (const annotation of spec.annotations) {
|
|
338
|
+
let resolved = null;
|
|
339
|
+
switch (annotation.type) {
|
|
340
|
+
case "text":
|
|
341
|
+
resolved = resolveTextAnnotation(annotation, scales, chartArea, isDark);
|
|
342
|
+
break;
|
|
343
|
+
case "range":
|
|
344
|
+
resolved = resolveRangeAnnotation(annotation, scales, chartArea, isDark);
|
|
345
|
+
break;
|
|
346
|
+
case "refline":
|
|
347
|
+
resolved = resolveRefLineAnnotation(annotation, scales, chartArea, isDark);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
if (resolved) {
|
|
351
|
+
if (annotation.type === "text" && obstacles.length > 0) {
|
|
352
|
+
nudgeAnnotationFromObstacles(resolved, annotation, scales, chartArea, obstacles);
|
|
353
|
+
}
|
|
354
|
+
annotations.push(resolved);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0));
|
|
358
|
+
return annotations;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// src/charts/bar/compute.ts
|
|
362
|
+
import { abbreviateNumber, formatNumber } from "@opendata-ai/openchart-core";
|
|
363
|
+
|
|
364
|
+
// src/charts/utils.ts
|
|
365
|
+
var DEFAULT_COLOR = "#1b7fa3";
|
|
366
|
+
function scaleValue(scale, scaleType, value) {
|
|
367
|
+
if (value == null) return null;
|
|
368
|
+
if (scaleType === "time") {
|
|
369
|
+
const date = value instanceof Date ? value : new Date(String(value));
|
|
370
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
371
|
+
return scale(date);
|
|
372
|
+
}
|
|
373
|
+
if (scaleType === "point" || scaleType === "band" || scaleType === "ordinal") {
|
|
374
|
+
const result = scale(String(value));
|
|
375
|
+
return result ?? null;
|
|
376
|
+
}
|
|
377
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
378
|
+
if (!Number.isFinite(num)) return null;
|
|
379
|
+
return scale(num);
|
|
380
|
+
}
|
|
381
|
+
function groupByField(data, field) {
|
|
382
|
+
const groups = /* @__PURE__ */ new Map();
|
|
383
|
+
if (!field) {
|
|
384
|
+
groups.set("__default__", data);
|
|
385
|
+
return groups;
|
|
386
|
+
}
|
|
387
|
+
for (const row of data) {
|
|
388
|
+
const key = String(row[field] ?? "__default__");
|
|
389
|
+
const existing = groups.get(key);
|
|
390
|
+
if (existing) {
|
|
391
|
+
existing.push(row);
|
|
392
|
+
} else {
|
|
393
|
+
groups.set(key, [row]);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return groups;
|
|
397
|
+
}
|
|
398
|
+
function getColor(scales, key, _index, fallback = DEFAULT_COLOR) {
|
|
399
|
+
if (scales.color && key !== "__default__") {
|
|
400
|
+
const colorScale = scales.color.scale;
|
|
401
|
+
return colorScale(key);
|
|
402
|
+
}
|
|
403
|
+
return scales.defaultColor ?? fallback;
|
|
404
|
+
}
|
|
405
|
+
function getSequentialColor(scales, value, fallback = DEFAULT_COLOR) {
|
|
406
|
+
if (scales.color?.type === "sequential") {
|
|
407
|
+
const colorScale = scales.color.scale;
|
|
408
|
+
return colorScale(value);
|
|
409
|
+
}
|
|
410
|
+
return scales.defaultColor ?? fallback;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// src/charts/bar/compute.ts
|
|
414
|
+
var MIN_BAR_WIDTH = 1;
|
|
415
|
+
function formatBarValue(value) {
|
|
416
|
+
if (Math.abs(value) >= 1e3) return abbreviateNumber(value);
|
|
417
|
+
return formatNumber(value);
|
|
418
|
+
}
|
|
419
|
+
function computeBarMarks(spec, scales, _chartArea, _strategy) {
|
|
420
|
+
const encoding = spec.encoding;
|
|
421
|
+
const xChannel = encoding.x;
|
|
422
|
+
const yChannel = encoding.y;
|
|
423
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
424
|
+
return [];
|
|
425
|
+
}
|
|
426
|
+
const yScale = scales.y.scale;
|
|
427
|
+
const xScale = scales.x.scale;
|
|
428
|
+
if (typeof yScale.bandwidth !== "function") {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
const bandwidth = yScale.bandwidth();
|
|
432
|
+
const baseline = xScale(0);
|
|
433
|
+
const colorField = encoding.color?.field;
|
|
434
|
+
const isSequentialColor = encoding.color?.type === "quantitative";
|
|
435
|
+
if (!colorField || isSequentialColor) {
|
|
436
|
+
return computeSimpleBars(
|
|
437
|
+
spec.data,
|
|
438
|
+
xChannel.field,
|
|
439
|
+
yChannel.field,
|
|
440
|
+
xScale,
|
|
441
|
+
yScale,
|
|
442
|
+
bandwidth,
|
|
443
|
+
baseline,
|
|
444
|
+
scales,
|
|
445
|
+
isSequentialColor
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
return computeStackedBars(
|
|
449
|
+
spec.data,
|
|
450
|
+
xChannel.field,
|
|
451
|
+
yChannel.field,
|
|
452
|
+
colorField,
|
|
453
|
+
xScale,
|
|
454
|
+
yScale,
|
|
455
|
+
bandwidth,
|
|
456
|
+
baseline,
|
|
457
|
+
scales
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
function computeStackedBars(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, _baseline, scales) {
|
|
461
|
+
const marks = [];
|
|
462
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
463
|
+
for (const [category, rows] of categoryGroups) {
|
|
464
|
+
const bandY = yScale(category);
|
|
465
|
+
if (bandY === void 0) continue;
|
|
466
|
+
let cumulativeValue = 0;
|
|
467
|
+
for (const row of rows) {
|
|
468
|
+
const groupKey = String(row[colorField] ?? "");
|
|
469
|
+
const value = Number(row[valueField] ?? 0);
|
|
470
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
471
|
+
const color = getColor(scales, groupKey);
|
|
472
|
+
const xLeft = xScale(cumulativeValue);
|
|
473
|
+
const xRight = xScale(cumulativeValue + value);
|
|
474
|
+
const barWidth = Math.max(Math.abs(xRight - xLeft), MIN_BAR_WIDTH);
|
|
475
|
+
const aria = {
|
|
476
|
+
label: `${category}, ${groupKey}: ${formatBarValue(value)}`
|
|
477
|
+
};
|
|
478
|
+
marks.push({
|
|
479
|
+
type: "rect",
|
|
480
|
+
x: xLeft,
|
|
481
|
+
y: bandY,
|
|
482
|
+
width: barWidth,
|
|
483
|
+
height: bandwidth,
|
|
484
|
+
fill: color,
|
|
485
|
+
cornerRadius: 0,
|
|
486
|
+
data: row,
|
|
487
|
+
aria
|
|
488
|
+
});
|
|
489
|
+
cumulativeValue += value;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return marks;
|
|
493
|
+
}
|
|
494
|
+
function computeSimpleBars(data, valueField, categoryField, xScale, yScale, bandwidth, baseline, scales, sequentialColor = false) {
|
|
495
|
+
const marks = [];
|
|
496
|
+
for (const row of data) {
|
|
497
|
+
const category = String(row[categoryField] ?? "");
|
|
498
|
+
const value = Number(row[valueField] ?? 0);
|
|
499
|
+
if (!Number.isFinite(value)) continue;
|
|
500
|
+
const bandY = yScale(category);
|
|
501
|
+
if (bandY === void 0) continue;
|
|
502
|
+
const color = sequentialColor ? getSequentialColor(scales, value) : getColor(scales, "__default__");
|
|
503
|
+
const xPos = value >= 0 ? baseline : xScale(value);
|
|
504
|
+
const barWidth = Math.max(Math.abs(xScale(value) - baseline), MIN_BAR_WIDTH);
|
|
505
|
+
const aria = {
|
|
506
|
+
label: `${category}: ${formatBarValue(value)}`
|
|
507
|
+
};
|
|
508
|
+
marks.push({
|
|
509
|
+
type: "rect",
|
|
510
|
+
x: xPos,
|
|
511
|
+
y: bandY,
|
|
512
|
+
width: barWidth,
|
|
513
|
+
height: bandwidth,
|
|
514
|
+
fill: color,
|
|
515
|
+
cornerRadius: 2,
|
|
516
|
+
data: row,
|
|
517
|
+
aria
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
return marks;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// src/charts/bar/labels.ts
|
|
524
|
+
import { estimateTextWidth as estimateTextWidth2, resolveCollisions } from "@opendata-ai/openchart-core";
|
|
525
|
+
var LABEL_FONT_SIZE = 11;
|
|
526
|
+
var LABEL_FONT_WEIGHT = 600;
|
|
527
|
+
var LABEL_PADDING = 6;
|
|
528
|
+
var MIN_WIDTH_FOR_INSIDE_LABEL = 40;
|
|
529
|
+
function computeBarLabels(marks, _chartArea, density = "auto") {
|
|
530
|
+
if (density === "none") return [];
|
|
531
|
+
const targetMarks = density === "endpoints" && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
532
|
+
const candidates = [];
|
|
533
|
+
for (const mark of targetMarks) {
|
|
534
|
+
const ariaLabel = mark.aria.label;
|
|
535
|
+
const lastColon = ariaLabel.lastIndexOf(":");
|
|
536
|
+
const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : "";
|
|
537
|
+
if (!valuePart) continue;
|
|
538
|
+
const textWidth = estimateTextWidth2(valuePart, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT);
|
|
539
|
+
const textHeight = LABEL_FONT_SIZE * 1.2;
|
|
540
|
+
const isStacked = mark.cornerRadius === 0;
|
|
541
|
+
const isInside = mark.width >= MIN_WIDTH_FOR_INSIDE_LABEL;
|
|
542
|
+
let anchorX;
|
|
543
|
+
let fill;
|
|
544
|
+
let textAnchor;
|
|
545
|
+
if (isStacked && isInside) {
|
|
546
|
+
anchorX = mark.x + mark.width / 2;
|
|
547
|
+
fill = "#ffffff";
|
|
548
|
+
textAnchor = "middle";
|
|
549
|
+
} else if (isInside) {
|
|
550
|
+
anchorX = mark.x + mark.width - LABEL_PADDING;
|
|
551
|
+
fill = "#ffffff";
|
|
552
|
+
textAnchor = "end";
|
|
553
|
+
} else {
|
|
554
|
+
anchorX = mark.x + mark.width + LABEL_PADDING;
|
|
555
|
+
fill = mark.fill;
|
|
556
|
+
textAnchor = "start";
|
|
557
|
+
}
|
|
558
|
+
const anchorY = mark.y + mark.height / 2;
|
|
559
|
+
candidates.push({
|
|
560
|
+
text: valuePart,
|
|
561
|
+
anchorX,
|
|
562
|
+
anchorY,
|
|
563
|
+
width: textWidth,
|
|
564
|
+
height: textHeight,
|
|
565
|
+
priority: "data",
|
|
566
|
+
style: {
|
|
567
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
568
|
+
fontSize: LABEL_FONT_SIZE,
|
|
569
|
+
fontWeight: LABEL_FONT_WEIGHT,
|
|
570
|
+
fill,
|
|
571
|
+
lineHeight: 1.2,
|
|
572
|
+
textAnchor,
|
|
573
|
+
dominantBaseline: "central"
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
if (candidates.length === 0) return [];
|
|
578
|
+
if (density === "all") {
|
|
579
|
+
return candidates.map((c) => ({
|
|
580
|
+
text: c.text,
|
|
581
|
+
x: c.anchorX,
|
|
582
|
+
y: c.anchorY,
|
|
583
|
+
style: c.style,
|
|
584
|
+
visible: true
|
|
585
|
+
}));
|
|
586
|
+
}
|
|
587
|
+
return resolveCollisions(candidates);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// src/charts/bar/index.ts
|
|
591
|
+
var barRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
592
|
+
const marks = computeBarMarks(spec, scales, chartArea, strategy);
|
|
593
|
+
const labels = computeBarLabels(marks, chartArea, spec.labels.density);
|
|
594
|
+
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
595
|
+
marks[i].label = labels[i];
|
|
596
|
+
}
|
|
597
|
+
return marks;
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
// src/charts/column/compute.ts
|
|
601
|
+
import { abbreviateNumber as abbreviateNumber2, formatNumber as formatNumber2 } from "@opendata-ai/openchart-core";
|
|
602
|
+
var MIN_COLUMN_HEIGHT = 1;
|
|
603
|
+
function formatColumnValue(value) {
|
|
604
|
+
if (Math.abs(value) >= 1e3) return abbreviateNumber2(value);
|
|
605
|
+
return formatNumber2(value);
|
|
606
|
+
}
|
|
607
|
+
function computeColumnMarks(spec, scales, _chartArea, _strategy) {
|
|
608
|
+
const encoding = spec.encoding;
|
|
609
|
+
const xChannel = encoding.x;
|
|
610
|
+
const yChannel = encoding.y;
|
|
611
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
const xScale = scales.x.scale;
|
|
615
|
+
const yScale = scales.y.scale;
|
|
616
|
+
if (typeof xScale.bandwidth !== "function") {
|
|
617
|
+
return [];
|
|
618
|
+
}
|
|
619
|
+
const bandwidth = xScale.bandwidth();
|
|
620
|
+
const baseline = yScale(0);
|
|
621
|
+
const colorField = encoding.color?.field;
|
|
622
|
+
const isSequentialColor = encoding.color?.type === "quantitative";
|
|
623
|
+
if (colorField && !isSequentialColor) {
|
|
624
|
+
const categoryGroups = groupByField(spec.data, xChannel.field);
|
|
625
|
+
const needsStacking = Array.from(categoryGroups.values()).some((rows) => rows.length > 1);
|
|
626
|
+
if (needsStacking) {
|
|
627
|
+
return computeStackedColumns(
|
|
628
|
+
spec.data,
|
|
629
|
+
xChannel.field,
|
|
630
|
+
yChannel.field,
|
|
631
|
+
colorField,
|
|
632
|
+
xScale,
|
|
633
|
+
yScale,
|
|
634
|
+
bandwidth,
|
|
635
|
+
baseline,
|
|
636
|
+
scales
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return computeColoredColumns(
|
|
640
|
+
spec.data,
|
|
641
|
+
xChannel.field,
|
|
642
|
+
yChannel.field,
|
|
643
|
+
colorField,
|
|
644
|
+
xScale,
|
|
645
|
+
yScale,
|
|
646
|
+
bandwidth,
|
|
647
|
+
baseline,
|
|
648
|
+
scales
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
return computeSimpleColumns(
|
|
652
|
+
spec.data,
|
|
653
|
+
xChannel.field,
|
|
654
|
+
yChannel.field,
|
|
655
|
+
xScale,
|
|
656
|
+
yScale,
|
|
657
|
+
bandwidth,
|
|
658
|
+
baseline,
|
|
659
|
+
scales,
|
|
660
|
+
isSequentialColor
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
function computeSimpleColumns(data, categoryField, valueField, xScale, yScale, bandwidth, baseline, scales, sequentialColor = false) {
|
|
664
|
+
const marks = [];
|
|
665
|
+
for (const row of data) {
|
|
666
|
+
const category = String(row[categoryField] ?? "");
|
|
667
|
+
const value = Number(row[valueField] ?? 0);
|
|
668
|
+
if (!Number.isFinite(value)) continue;
|
|
669
|
+
const bandX = xScale(category);
|
|
670
|
+
if (bandX === void 0) continue;
|
|
671
|
+
const color = sequentialColor ? getSequentialColor(scales, value) : getColor(scales, "__default__");
|
|
672
|
+
const yPos = yScale(value);
|
|
673
|
+
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
674
|
+
const y = value >= 0 ? yPos : baseline;
|
|
675
|
+
const aria = {
|
|
676
|
+
label: `${category}: ${formatColumnValue(value)}`
|
|
677
|
+
};
|
|
678
|
+
marks.push({
|
|
679
|
+
type: "rect",
|
|
680
|
+
x: bandX,
|
|
681
|
+
y,
|
|
682
|
+
width: bandwidth,
|
|
683
|
+
height: columnHeight,
|
|
684
|
+
fill: color,
|
|
685
|
+
cornerRadius: 2,
|
|
686
|
+
data: row,
|
|
687
|
+
aria
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
return marks;
|
|
691
|
+
}
|
|
692
|
+
function computeColoredColumns(data, categoryField, valueField, colorField, xScale, yScale, bandwidth, baseline, scales) {
|
|
693
|
+
const marks = [];
|
|
694
|
+
for (const row of data) {
|
|
695
|
+
const category = String(row[categoryField] ?? "");
|
|
696
|
+
const value = Number(row[valueField] ?? 0);
|
|
697
|
+
if (!Number.isFinite(value)) continue;
|
|
698
|
+
const bandX = xScale(category);
|
|
699
|
+
if (bandX === void 0) continue;
|
|
700
|
+
const groupKey = String(row[colorField] ?? "");
|
|
701
|
+
const color = getColor(scales, groupKey);
|
|
702
|
+
const yPos = yScale(value);
|
|
703
|
+
const columnHeight = Math.max(Math.abs(baseline - yPos), MIN_COLUMN_HEIGHT);
|
|
704
|
+
const y = value >= 0 ? yPos : baseline;
|
|
705
|
+
const aria = {
|
|
706
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(value)}`
|
|
707
|
+
};
|
|
708
|
+
marks.push({
|
|
709
|
+
type: "rect",
|
|
710
|
+
x: bandX,
|
|
711
|
+
y,
|
|
712
|
+
width: bandwidth,
|
|
713
|
+
height: columnHeight,
|
|
714
|
+
fill: color,
|
|
715
|
+
cornerRadius: 2,
|
|
716
|
+
data: row,
|
|
717
|
+
aria
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return marks;
|
|
721
|
+
}
|
|
722
|
+
function computeStackedColumns(data, categoryField, valueField, colorField, xScale, yScale, bandwidth, _baseline, scales) {
|
|
723
|
+
const marks = [];
|
|
724
|
+
const categoryGroups = groupByField(data, categoryField);
|
|
725
|
+
for (const [category, rows] of categoryGroups) {
|
|
726
|
+
const bandX = xScale(category);
|
|
727
|
+
if (bandX === void 0) continue;
|
|
728
|
+
let cumulativeValue = 0;
|
|
729
|
+
for (const row of rows) {
|
|
730
|
+
const groupKey = String(row[colorField] ?? "");
|
|
731
|
+
const value = Number(row[valueField] ?? 0);
|
|
732
|
+
if (!Number.isFinite(value) || value <= 0) continue;
|
|
733
|
+
const color = getColor(scales, groupKey);
|
|
734
|
+
const yTop = yScale(cumulativeValue + value);
|
|
735
|
+
const yBottom = yScale(cumulativeValue);
|
|
736
|
+
const columnHeight = Math.max(Math.abs(yBottom - yTop), MIN_COLUMN_HEIGHT);
|
|
737
|
+
const aria = {
|
|
738
|
+
label: `${category}, ${groupKey}: ${formatColumnValue(value)}`
|
|
739
|
+
};
|
|
740
|
+
marks.push({
|
|
741
|
+
type: "rect",
|
|
742
|
+
x: bandX,
|
|
743
|
+
y: yTop,
|
|
744
|
+
width: bandwidth,
|
|
745
|
+
height: columnHeight,
|
|
746
|
+
fill: color,
|
|
747
|
+
cornerRadius: 0,
|
|
748
|
+
data: row,
|
|
749
|
+
aria
|
|
750
|
+
});
|
|
751
|
+
cumulativeValue += value;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
return marks;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// src/charts/column/labels.ts
|
|
758
|
+
import { estimateTextWidth as estimateTextWidth3, resolveCollisions as resolveCollisions2 } from "@opendata-ai/openchart-core";
|
|
759
|
+
var LABEL_FONT_SIZE2 = 10;
|
|
760
|
+
var LABEL_FONT_WEIGHT2 = 600;
|
|
761
|
+
var LABEL_OFFSET_Y = 6;
|
|
762
|
+
function computeColumnLabels(marks, _chartArea, density = "auto") {
|
|
763
|
+
if (density === "none") return [];
|
|
764
|
+
const targetMarks = density === "endpoints" && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
765
|
+
const candidates = [];
|
|
766
|
+
for (const mark of targetMarks) {
|
|
767
|
+
const ariaLabel = mark.aria.label;
|
|
768
|
+
const lastColon = ariaLabel.lastIndexOf(":");
|
|
769
|
+
const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : "";
|
|
770
|
+
if (!valuePart) continue;
|
|
771
|
+
const numericValue = parseFloat(valuePart);
|
|
772
|
+
const isNegative = Number.isFinite(numericValue) && numericValue < 0;
|
|
773
|
+
const textWidth = estimateTextWidth3(valuePart, LABEL_FONT_SIZE2, LABEL_FONT_WEIGHT2);
|
|
774
|
+
const textHeight = LABEL_FONT_SIZE2 * 1.2;
|
|
775
|
+
const anchorX = mark.x + mark.width / 2 - textWidth / 2;
|
|
776
|
+
const anchorY = isNegative ? mark.y + mark.height + LABEL_OFFSET_Y : mark.y - LABEL_OFFSET_Y - textHeight;
|
|
777
|
+
candidates.push({
|
|
778
|
+
text: valuePart,
|
|
779
|
+
anchorX,
|
|
780
|
+
anchorY,
|
|
781
|
+
width: textWidth,
|
|
782
|
+
height: textHeight,
|
|
783
|
+
priority: "data",
|
|
784
|
+
style: {
|
|
785
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
786
|
+
fontSize: LABEL_FONT_SIZE2,
|
|
787
|
+
fontWeight: LABEL_FONT_WEIGHT2,
|
|
788
|
+
fill: mark.fill,
|
|
789
|
+
lineHeight: 1.2,
|
|
790
|
+
textAnchor: "middle",
|
|
791
|
+
dominantBaseline: isNegative ? "hanging" : "auto"
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
if (candidates.length === 0) return [];
|
|
796
|
+
if (density === "all") {
|
|
797
|
+
return candidates.map((c) => ({
|
|
798
|
+
text: c.text,
|
|
799
|
+
x: c.anchorX,
|
|
800
|
+
y: c.anchorY,
|
|
801
|
+
style: c.style,
|
|
802
|
+
visible: true
|
|
803
|
+
}));
|
|
804
|
+
}
|
|
805
|
+
return resolveCollisions2(candidates);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// src/charts/column/index.ts
|
|
809
|
+
var columnRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
810
|
+
const marks = computeColumnMarks(spec, scales, chartArea, strategy);
|
|
811
|
+
const labels = computeColumnLabels(marks, chartArea, spec.labels.density);
|
|
812
|
+
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
813
|
+
marks[i].label = labels[i];
|
|
814
|
+
}
|
|
815
|
+
return marks;
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
// src/charts/dot/compute.ts
|
|
819
|
+
var DOT_RADIUS = 6;
|
|
820
|
+
var STEM_WIDTH = 2;
|
|
821
|
+
var STEM_COLOR = "#cccccc";
|
|
822
|
+
function computeDotMarks(spec, scales, _chartArea, _strategy) {
|
|
823
|
+
const encoding = spec.encoding;
|
|
824
|
+
const xChannel = encoding.x;
|
|
825
|
+
const yChannel = encoding.y;
|
|
826
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
const xScale = scales.x.scale;
|
|
830
|
+
const yScale = scales.y.scale;
|
|
831
|
+
if (typeof yScale.bandwidth !== "function") {
|
|
832
|
+
return [];
|
|
833
|
+
}
|
|
834
|
+
const bandwidth = yScale.bandwidth();
|
|
835
|
+
const baseline = xScale(0);
|
|
836
|
+
const colorField = encoding.color?.field;
|
|
837
|
+
if (colorField) {
|
|
838
|
+
return computeDumbbellMarks(
|
|
839
|
+
spec.data,
|
|
840
|
+
xChannel.field,
|
|
841
|
+
yChannel.field,
|
|
842
|
+
colorField,
|
|
843
|
+
xScale,
|
|
844
|
+
yScale,
|
|
845
|
+
bandwidth,
|
|
846
|
+
scales
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
return computeLollipopMarks(
|
|
850
|
+
spec.data,
|
|
851
|
+
xChannel.field,
|
|
852
|
+
yChannel.field,
|
|
853
|
+
xScale,
|
|
854
|
+
yScale,
|
|
855
|
+
bandwidth,
|
|
856
|
+
baseline,
|
|
857
|
+
scales
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
function computeDumbbellMarks(data, valueField, categoryField, colorField, xScale, yScale, bandwidth, scales) {
|
|
861
|
+
const marks = [];
|
|
862
|
+
const categoryGroups = groupByField([...data], categoryField);
|
|
863
|
+
for (const [category, rows] of categoryGroups) {
|
|
864
|
+
const bandY = yScale(category);
|
|
865
|
+
if (bandY === void 0) continue;
|
|
866
|
+
const cy = bandY + bandwidth / 2;
|
|
867
|
+
const xValues = [];
|
|
868
|
+
for (const row of rows) {
|
|
869
|
+
const value = Number(row[valueField] ?? 0);
|
|
870
|
+
if (Number.isFinite(value)) xValues.push(value);
|
|
871
|
+
}
|
|
872
|
+
if (xValues.length === 0) continue;
|
|
873
|
+
const minVal = Math.min(...xValues);
|
|
874
|
+
const maxVal = Math.max(...xValues);
|
|
875
|
+
const xLeft = xScale(minVal);
|
|
876
|
+
const xRight = xScale(maxVal);
|
|
877
|
+
const barWidth = Math.abs(xRight - xLeft);
|
|
878
|
+
if (barWidth > 0) {
|
|
879
|
+
const stemAria = {
|
|
880
|
+
label: `Range for ${category}: ${minVal} to ${maxVal}`
|
|
881
|
+
};
|
|
882
|
+
marks.push({
|
|
883
|
+
type: "rect",
|
|
884
|
+
x: Math.min(xLeft, xRight),
|
|
885
|
+
y: cy - STEM_WIDTH / 2,
|
|
886
|
+
width: barWidth,
|
|
887
|
+
height: STEM_WIDTH,
|
|
888
|
+
fill: STEM_COLOR,
|
|
889
|
+
data: rows[0],
|
|
890
|
+
aria: stemAria
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
for (const row of rows) {
|
|
894
|
+
const value = Number(row[valueField] ?? 0);
|
|
895
|
+
if (!Number.isFinite(value)) continue;
|
|
896
|
+
const cx = xScale(value);
|
|
897
|
+
const colorCategory = String(row[colorField] ?? "");
|
|
898
|
+
const color = getColor(scales, colorCategory);
|
|
899
|
+
const dotAria = {
|
|
900
|
+
label: `${category}, ${colorCategory}: ${value}`
|
|
901
|
+
};
|
|
902
|
+
marks.push({
|
|
903
|
+
type: "point",
|
|
904
|
+
cx,
|
|
905
|
+
cy,
|
|
906
|
+
r: DOT_RADIUS,
|
|
907
|
+
fill: color,
|
|
908
|
+
stroke: "#ffffff",
|
|
909
|
+
strokeWidth: 2,
|
|
910
|
+
data: row,
|
|
911
|
+
aria: dotAria
|
|
912
|
+
});
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return marks;
|
|
916
|
+
}
|
|
917
|
+
function computeLollipopMarks(data, valueField, categoryField, xScale, yScale, bandwidth, baseline, scales) {
|
|
918
|
+
const marks = [];
|
|
919
|
+
for (const row of data) {
|
|
920
|
+
const category = String(row[categoryField] ?? "");
|
|
921
|
+
const value = Number(row[valueField] ?? 0);
|
|
922
|
+
if (!Number.isFinite(value)) continue;
|
|
923
|
+
const bandY = yScale(category);
|
|
924
|
+
if (bandY === void 0) continue;
|
|
925
|
+
const cx = xScale(value);
|
|
926
|
+
const cy = bandY + bandwidth / 2;
|
|
927
|
+
const color = getColor(scales, "__default__");
|
|
928
|
+
const stemX = Math.min(baseline, cx);
|
|
929
|
+
const stemWidth = Math.abs(cx - baseline);
|
|
930
|
+
if (stemWidth > 0) {
|
|
931
|
+
const stemAria = {
|
|
932
|
+
label: `Stem for ${category}`
|
|
933
|
+
};
|
|
934
|
+
marks.push({
|
|
935
|
+
type: "rect",
|
|
936
|
+
x: stemX,
|
|
937
|
+
y: cy - STEM_WIDTH / 2,
|
|
938
|
+
width: stemWidth,
|
|
939
|
+
height: STEM_WIDTH,
|
|
940
|
+
fill: STEM_COLOR,
|
|
941
|
+
data: row,
|
|
942
|
+
aria: stemAria
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
const dotAria = {
|
|
946
|
+
label: `${category}: ${value}`
|
|
947
|
+
};
|
|
948
|
+
marks.push({
|
|
949
|
+
type: "point",
|
|
950
|
+
cx,
|
|
951
|
+
cy,
|
|
952
|
+
r: DOT_RADIUS,
|
|
953
|
+
fill: color,
|
|
954
|
+
stroke: "#ffffff",
|
|
955
|
+
strokeWidth: 2,
|
|
956
|
+
data: row,
|
|
957
|
+
aria: dotAria
|
|
958
|
+
});
|
|
959
|
+
}
|
|
960
|
+
return marks;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// src/charts/dot/labels.ts
|
|
964
|
+
import { estimateTextWidth as estimateTextWidth4, resolveCollisions as resolveCollisions3 } from "@opendata-ai/openchart-core";
|
|
965
|
+
var LABEL_FONT_SIZE3 = 11;
|
|
966
|
+
var LABEL_FONT_WEIGHT3 = 600;
|
|
967
|
+
var LABEL_OFFSET_X = 10;
|
|
968
|
+
function computeDotLabels(marks, _chartArea, density = "auto") {
|
|
969
|
+
if (density === "none") return [];
|
|
970
|
+
const targetMarks = density === "endpoints" && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
971
|
+
const candidates = [];
|
|
972
|
+
for (const mark of targetMarks) {
|
|
973
|
+
const ariaLabel = mark.aria.label;
|
|
974
|
+
const lastColon = ariaLabel.lastIndexOf(":");
|
|
975
|
+
const valuePart = lastColon >= 0 ? ariaLabel.slice(lastColon + 1).trim() : "";
|
|
976
|
+
if (!valuePart) continue;
|
|
977
|
+
const textWidth = estimateTextWidth4(valuePart, LABEL_FONT_SIZE3, LABEL_FONT_WEIGHT3);
|
|
978
|
+
const textHeight = LABEL_FONT_SIZE3 * 1.2;
|
|
979
|
+
candidates.push({
|
|
980
|
+
text: valuePart,
|
|
981
|
+
anchorX: mark.cx + mark.r + LABEL_OFFSET_X,
|
|
982
|
+
anchorY: mark.cy - textHeight / 2,
|
|
983
|
+
width: textWidth,
|
|
984
|
+
height: textHeight,
|
|
985
|
+
priority: "data",
|
|
986
|
+
style: {
|
|
987
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
988
|
+
fontSize: LABEL_FONT_SIZE3,
|
|
989
|
+
fontWeight: LABEL_FONT_WEIGHT3,
|
|
990
|
+
fill: mark.fill,
|
|
991
|
+
lineHeight: 1.2,
|
|
992
|
+
textAnchor: "start",
|
|
993
|
+
dominantBaseline: "central"
|
|
994
|
+
}
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
if (candidates.length === 0) return [];
|
|
998
|
+
if (density === "all") {
|
|
999
|
+
return candidates.map((c) => ({
|
|
1000
|
+
text: c.text,
|
|
1001
|
+
x: c.anchorX,
|
|
1002
|
+
y: c.anchorY,
|
|
1003
|
+
style: c.style,
|
|
1004
|
+
visible: true
|
|
1005
|
+
}));
|
|
1006
|
+
}
|
|
1007
|
+
return resolveCollisions3(candidates);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
// src/charts/dot/index.ts
|
|
1011
|
+
var dotRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
1012
|
+
const marks = computeDotMarks(spec, scales, chartArea, strategy);
|
|
1013
|
+
const pointMarks = marks.filter((m) => m.type === "point");
|
|
1014
|
+
const labels = computeDotLabels(pointMarks, chartArea, spec.labels.density);
|
|
1015
|
+
let labelIdx = 0;
|
|
1016
|
+
for (const mark of marks) {
|
|
1017
|
+
if (mark.type === "point" && labelIdx < labels.length) {
|
|
1018
|
+
mark.label = labels[labelIdx];
|
|
1019
|
+
labelIdx++;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
return marks;
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
// src/charts/line/area.ts
|
|
1026
|
+
import { area, curveMonotoneX, line, stack, stackOffsetNone, stackOrderNone } from "d3-shape";
|
|
1027
|
+
var DEFAULT_FILL_OPACITY = 0.15;
|
|
1028
|
+
function computeSingleArea(spec, scales, _chartArea) {
|
|
1029
|
+
const encoding = spec.encoding;
|
|
1030
|
+
const xChannel = encoding.x;
|
|
1031
|
+
const yChannel = encoding.y;
|
|
1032
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) return [];
|
|
1033
|
+
const yScale = scales.y.scale;
|
|
1034
|
+
const domain = yScale.domain();
|
|
1035
|
+
const baselineY = yScale(Math.min(domain[0], domain[1]));
|
|
1036
|
+
const colorField = encoding.color?.field;
|
|
1037
|
+
const groups = /* @__PURE__ */ new Map();
|
|
1038
|
+
if (!colorField) {
|
|
1039
|
+
groups.set("__default__", spec.data);
|
|
1040
|
+
} else {
|
|
1041
|
+
for (const row of spec.data) {
|
|
1042
|
+
const key = String(row[colorField] ?? "__default__");
|
|
1043
|
+
const existing = groups.get(key);
|
|
1044
|
+
if (existing) {
|
|
1045
|
+
existing.push(row);
|
|
1046
|
+
} else {
|
|
1047
|
+
groups.set(key, [row]);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
const marks = [];
|
|
1052
|
+
for (const [seriesKey, rows] of groups) {
|
|
1053
|
+
const color = getColor(scales, seriesKey);
|
|
1054
|
+
const validPoints = [];
|
|
1055
|
+
for (const row of rows) {
|
|
1056
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
1057
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
1058
|
+
if (xVal === null || yVal === null) continue;
|
|
1059
|
+
validPoints.push({
|
|
1060
|
+
x: xVal,
|
|
1061
|
+
yTop: yVal,
|
|
1062
|
+
yBottom: baselineY,
|
|
1063
|
+
row
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
if (validPoints.length === 0) continue;
|
|
1067
|
+
const areaGenerator = area().x((d) => d.x).y0((d) => d.yBottom).y1((d) => d.yTop).curve(curveMonotoneX);
|
|
1068
|
+
const pathStr = areaGenerator(validPoints) ?? "";
|
|
1069
|
+
const topLineGenerator = line().x((d) => d.x).y((d) => d.yTop).curve(curveMonotoneX);
|
|
1070
|
+
const topPathStr = topLineGenerator(validPoints) ?? "";
|
|
1071
|
+
const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
|
|
1072
|
+
const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
|
|
1073
|
+
const ariaLabel = seriesKey === "__default__" ? `Area with ${validPoints.length} data points` : `${seriesKey}: area with ${validPoints.length} data points`;
|
|
1074
|
+
const aria = { label: ariaLabel };
|
|
1075
|
+
marks.push({
|
|
1076
|
+
type: "area",
|
|
1077
|
+
topPoints,
|
|
1078
|
+
bottomPoints,
|
|
1079
|
+
path: pathStr,
|
|
1080
|
+
topPath: topPathStr,
|
|
1081
|
+
fill: color,
|
|
1082
|
+
fillOpacity: DEFAULT_FILL_OPACITY,
|
|
1083
|
+
stroke: color,
|
|
1084
|
+
strokeWidth: 2,
|
|
1085
|
+
seriesKey: seriesKey === "__default__" ? void 0 : seriesKey,
|
|
1086
|
+
data: validPoints.map((p) => p.row),
|
|
1087
|
+
aria
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
return marks;
|
|
1091
|
+
}
|
|
1092
|
+
function computeStackedArea(spec, scales, chartArea) {
|
|
1093
|
+
const encoding = spec.encoding;
|
|
1094
|
+
const xChannel = encoding.x;
|
|
1095
|
+
const yChannel = encoding.y;
|
|
1096
|
+
const colorField = encoding.color?.field;
|
|
1097
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y || !colorField) {
|
|
1098
|
+
return computeSingleArea(spec, scales, chartArea);
|
|
1099
|
+
}
|
|
1100
|
+
const seriesKeys = /* @__PURE__ */ new Set();
|
|
1101
|
+
const xValueSet = /* @__PURE__ */ new Set();
|
|
1102
|
+
const rowsByXSeries = /* @__PURE__ */ new Map();
|
|
1103
|
+
const rowsByX = /* @__PURE__ */ new Map();
|
|
1104
|
+
for (const row of spec.data) {
|
|
1105
|
+
const xStr = String(row[xChannel.field]);
|
|
1106
|
+
const series = String(row[colorField]);
|
|
1107
|
+
seriesKeys.add(series);
|
|
1108
|
+
xValueSet.add(xStr);
|
|
1109
|
+
rowsByXSeries.set(`${xStr}::${series}`, row);
|
|
1110
|
+
const existing = rowsByX.get(xStr);
|
|
1111
|
+
if (existing) {
|
|
1112
|
+
existing.push(row);
|
|
1113
|
+
} else {
|
|
1114
|
+
rowsByX.set(xStr, [row]);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const keys = Array.from(seriesKeys);
|
|
1118
|
+
const xValues = Array.from(xValueSet);
|
|
1119
|
+
const pivotData = xValues.map((xVal) => {
|
|
1120
|
+
const pivot = { __x__: xVal };
|
|
1121
|
+
for (const key of keys) {
|
|
1122
|
+
pivot[key] = 0;
|
|
1123
|
+
}
|
|
1124
|
+
const xRows = rowsByX.get(xVal);
|
|
1125
|
+
if (xRows) {
|
|
1126
|
+
for (const row of xRows) {
|
|
1127
|
+
const series = String(row[colorField]);
|
|
1128
|
+
pivot[series] = row[yChannel.field] ?? 0;
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
return pivot;
|
|
1132
|
+
});
|
|
1133
|
+
const stackGenerator = stack().keys(keys).order(stackOrderNone).offset(stackOffsetNone);
|
|
1134
|
+
const stackedData = stackGenerator(pivotData);
|
|
1135
|
+
const yScale = scales.y.scale;
|
|
1136
|
+
const marks = [];
|
|
1137
|
+
for (const layer of stackedData) {
|
|
1138
|
+
const seriesKey = layer.key;
|
|
1139
|
+
const color = getColor(scales, seriesKey);
|
|
1140
|
+
const validPoints = [];
|
|
1141
|
+
for (const d of layer) {
|
|
1142
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, d.data.__x__);
|
|
1143
|
+
if (xVal === null) continue;
|
|
1144
|
+
const yTop = yScale(d[1]);
|
|
1145
|
+
const yBottom = yScale(d[0]);
|
|
1146
|
+
validPoints.push({ x: xVal, yTop, yBottom });
|
|
1147
|
+
}
|
|
1148
|
+
if (validPoints.length === 0) continue;
|
|
1149
|
+
const areaGenerator = area().x((p) => p.x).y0((p) => p.yBottom).y1((p) => p.yTop).curve(curveMonotoneX);
|
|
1150
|
+
const pathStr = areaGenerator(validPoints) ?? "";
|
|
1151
|
+
const topLineGenerator = line().x((p) => p.x).y((p) => p.yTop).curve(curveMonotoneX);
|
|
1152
|
+
const topPathStr = topLineGenerator(validPoints) ?? "";
|
|
1153
|
+
const topPoints = validPoints.map((p) => ({ x: p.x, y: p.yTop }));
|
|
1154
|
+
const bottomPoints = validPoints.map((p) => ({ x: p.x, y: p.yBottom }));
|
|
1155
|
+
const aria = {
|
|
1156
|
+
label: `${seriesKey}: stacked area with ${validPoints.length} data points`
|
|
1157
|
+
};
|
|
1158
|
+
marks.push({
|
|
1159
|
+
type: "area",
|
|
1160
|
+
topPoints,
|
|
1161
|
+
bottomPoints,
|
|
1162
|
+
path: pathStr,
|
|
1163
|
+
topPath: topPathStr,
|
|
1164
|
+
fill: color,
|
|
1165
|
+
fillOpacity: 0.7,
|
|
1166
|
+
// Higher opacity for stacked so layers are visible
|
|
1167
|
+
stroke: color,
|
|
1168
|
+
strokeWidth: 1,
|
|
1169
|
+
seriesKey,
|
|
1170
|
+
data: layer.map((d) => {
|
|
1171
|
+
const xStr = String(d.data.__x__);
|
|
1172
|
+
return rowsByXSeries.get(`${xStr}::${seriesKey}`) ?? d.data;
|
|
1173
|
+
}),
|
|
1174
|
+
aria
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
return marks;
|
|
1178
|
+
}
|
|
1179
|
+
function computeAreaMarks(spec, scales, chartArea) {
|
|
1180
|
+
const encoding = spec.encoding;
|
|
1181
|
+
const hasColor = !!encoding.color;
|
|
1182
|
+
if (hasColor) {
|
|
1183
|
+
return computeStackedArea(spec, scales, chartArea);
|
|
1184
|
+
}
|
|
1185
|
+
return computeSingleArea(spec, scales, chartArea);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
// src/charts/line/compute.ts
|
|
1189
|
+
import { curveMonotoneX as curveMonotoneX2, line as line2 } from "d3-shape";
|
|
1190
|
+
var DEFAULT_STROKE_WIDTH = 2.5;
|
|
1191
|
+
var DEFAULT_POINT_RADIUS = 3;
|
|
1192
|
+
function computeLineMarks(spec, scales, _chartArea, _strategy) {
|
|
1193
|
+
const encoding = spec.encoding;
|
|
1194
|
+
const xChannel = encoding.x;
|
|
1195
|
+
const yChannel = encoding.y;
|
|
1196
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
1197
|
+
return [];
|
|
1198
|
+
}
|
|
1199
|
+
const colorField = encoding.color?.field;
|
|
1200
|
+
const groups = groupByField(spec.data, colorField);
|
|
1201
|
+
const marks = [];
|
|
1202
|
+
for (const [seriesKey, rows] of groups) {
|
|
1203
|
+
const color = getColor(scales, seriesKey);
|
|
1204
|
+
const pointsWithData = [];
|
|
1205
|
+
const segments = [];
|
|
1206
|
+
let currentSegment = [];
|
|
1207
|
+
for (const row of rows) {
|
|
1208
|
+
const xVal = scaleValue(scales.x.scale, scales.x.type, row[xChannel.field]);
|
|
1209
|
+
const yVal = scaleValue(scales.y.scale, scales.y.type, row[yChannel.field]);
|
|
1210
|
+
if (xVal === null || yVal === null) {
|
|
1211
|
+
if (currentSegment.length > 0) {
|
|
1212
|
+
segments.push(currentSegment);
|
|
1213
|
+
currentSegment = [];
|
|
1214
|
+
}
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
const point = { x: xVal, y: yVal };
|
|
1218
|
+
currentSegment.push(point);
|
|
1219
|
+
pointsWithData.push({ ...point, row });
|
|
1220
|
+
}
|
|
1221
|
+
if (currentSegment.length > 0) {
|
|
1222
|
+
segments.push(currentSegment);
|
|
1223
|
+
}
|
|
1224
|
+
const lineGenerator = line2().x((d) => d.x).y((d) => d.y).curve(curveMonotoneX2);
|
|
1225
|
+
const allPoints = [];
|
|
1226
|
+
const pathParts = [];
|
|
1227
|
+
for (const segment of segments) {
|
|
1228
|
+
if (segment.length === 0) continue;
|
|
1229
|
+
const pathStr = lineGenerator(segment);
|
|
1230
|
+
if (pathStr) {
|
|
1231
|
+
pathParts.push(pathStr);
|
|
1232
|
+
}
|
|
1233
|
+
allPoints.push(...segment);
|
|
1234
|
+
}
|
|
1235
|
+
if (allPoints.length === 0) continue;
|
|
1236
|
+
const ariaLabel = seriesKey === "__default__" ? `Line with ${allPoints.length} data points` : `${seriesKey}: line with ${allPoints.length} data points`;
|
|
1237
|
+
const aria = {
|
|
1238
|
+
label: ariaLabel
|
|
1239
|
+
};
|
|
1240
|
+
const combinedPath = pathParts.join(" ");
|
|
1241
|
+
const lineMark = {
|
|
1242
|
+
type: "line",
|
|
1243
|
+
points: allPoints,
|
|
1244
|
+
path: combinedPath,
|
|
1245
|
+
stroke: color,
|
|
1246
|
+
strokeWidth: DEFAULT_STROKE_WIDTH,
|
|
1247
|
+
seriesKey: seriesKey === "__default__" ? void 0 : seriesKey,
|
|
1248
|
+
data: pointsWithData.map((p) => p.row),
|
|
1249
|
+
aria
|
|
1250
|
+
};
|
|
1251
|
+
marks.push(lineMark);
|
|
1252
|
+
for (let i = 0; i < pointsWithData.length; i++) {
|
|
1253
|
+
const p = pointsWithData[i];
|
|
1254
|
+
const pointMark = {
|
|
1255
|
+
type: "point",
|
|
1256
|
+
cx: p.x,
|
|
1257
|
+
cy: p.y,
|
|
1258
|
+
r: DEFAULT_POINT_RADIUS,
|
|
1259
|
+
fill: color,
|
|
1260
|
+
stroke: "#ffffff",
|
|
1261
|
+
strokeWidth: 1.5,
|
|
1262
|
+
fillOpacity: 0,
|
|
1263
|
+
data: p.row,
|
|
1264
|
+
aria: {
|
|
1265
|
+
label: `Data point: ${xChannel.field}=${String(p.row[xChannel.field])}, ${yChannel.field}=${String(p.row[yChannel.field])}`
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
marks.push(pointMark);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
return marks;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// src/charts/line/labels.ts
|
|
1275
|
+
import { estimateTextWidth as estimateTextWidth5, resolveCollisions as resolveCollisions4 } from "@opendata-ai/openchart-core";
|
|
1276
|
+
var LABEL_FONT_SIZE4 = 11;
|
|
1277
|
+
var LABEL_FONT_WEIGHT4 = 600;
|
|
1278
|
+
var LABEL_OFFSET_X2 = 6;
|
|
1279
|
+
function computeLineLabels(marks, strategy, density = "auto", labelOffsets) {
|
|
1280
|
+
const result = /* @__PURE__ */ new Map();
|
|
1281
|
+
if (density === "none") return result;
|
|
1282
|
+
if (strategy.labelMode === "none") {
|
|
1283
|
+
return result;
|
|
1284
|
+
}
|
|
1285
|
+
const candidates = [];
|
|
1286
|
+
const seriesOrder = [];
|
|
1287
|
+
for (const mark of marks) {
|
|
1288
|
+
if (mark.points.length === 0) continue;
|
|
1289
|
+
const labelText = mark.seriesKey ?? "";
|
|
1290
|
+
if (!labelText) continue;
|
|
1291
|
+
const lastPoint = mark.points[mark.points.length - 1];
|
|
1292
|
+
const textWidth = estimateTextWidth5(labelText, LABEL_FONT_SIZE4, LABEL_FONT_WEIGHT4);
|
|
1293
|
+
const textHeight = LABEL_FONT_SIZE4 * 1.2;
|
|
1294
|
+
candidates.push({
|
|
1295
|
+
text: labelText,
|
|
1296
|
+
anchorX: lastPoint.x + LABEL_OFFSET_X2,
|
|
1297
|
+
anchorY: lastPoint.y - textHeight / 2,
|
|
1298
|
+
width: textWidth,
|
|
1299
|
+
height: textHeight,
|
|
1300
|
+
priority: "data",
|
|
1301
|
+
style: {
|
|
1302
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
1303
|
+
fontSize: LABEL_FONT_SIZE4,
|
|
1304
|
+
fontWeight: LABEL_FONT_WEIGHT4,
|
|
1305
|
+
fill: mark.stroke,
|
|
1306
|
+
lineHeight: 1.2,
|
|
1307
|
+
textAnchor: "start",
|
|
1308
|
+
dominantBaseline: "central"
|
|
1309
|
+
}
|
|
1310
|
+
});
|
|
1311
|
+
seriesOrder.push(labelText);
|
|
1312
|
+
}
|
|
1313
|
+
if (candidates.length === 0) return result;
|
|
1314
|
+
if (density === "all") {
|
|
1315
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
1316
|
+
const c = candidates[i];
|
|
1317
|
+
const seriesKey = seriesOrder[i];
|
|
1318
|
+
const userOffset = labelOffsets?.[seriesKey];
|
|
1319
|
+
result.set(seriesKey, {
|
|
1320
|
+
text: c.text,
|
|
1321
|
+
x: c.anchorX + (userOffset?.dx ?? 0),
|
|
1322
|
+
y: c.anchorY + (userOffset?.dy ?? 0),
|
|
1323
|
+
style: c.style,
|
|
1324
|
+
visible: true
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
const resolved = resolveCollisions4(candidates);
|
|
1330
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
1331
|
+
const seriesKey = seriesOrder[i];
|
|
1332
|
+
const label = resolved[i];
|
|
1333
|
+
const userOffset = labelOffsets?.[seriesKey];
|
|
1334
|
+
if (userOffset) {
|
|
1335
|
+
label.x += userOffset.dx ?? 0;
|
|
1336
|
+
label.y += userOffset.dy ?? 0;
|
|
1337
|
+
}
|
|
1338
|
+
result.set(seriesKey, label);
|
|
1339
|
+
}
|
|
1340
|
+
return result;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// src/charts/line/index.ts
|
|
1344
|
+
var lineRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
1345
|
+
const marks = computeLineMarks(spec, scales, chartArea, strategy);
|
|
1346
|
+
const lineMarks = marks.filter((m) => m.type === "line");
|
|
1347
|
+
const labelMap = computeLineLabels(lineMarks, strategy, spec.labels.density, spec.labels.offsets);
|
|
1348
|
+
for (const mark of marks) {
|
|
1349
|
+
if (mark.type === "line" && mark.seriesKey) {
|
|
1350
|
+
const label = labelMap.get(mark.seriesKey);
|
|
1351
|
+
if (label) {
|
|
1352
|
+
mark.label = label;
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
return marks;
|
|
1357
|
+
};
|
|
1358
|
+
var areaRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
1359
|
+
const areas = computeAreaMarks(spec, scales, chartArea);
|
|
1360
|
+
const lines = computeLineMarks(spec, scales, chartArea, strategy);
|
|
1361
|
+
return [...areas, ...lines];
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
// src/charts/pie/compute.ts
|
|
1365
|
+
import { arc as d3Arc, pie as d3Pie } from "d3-shape";
|
|
1366
|
+
var SMALL_SLICE_THRESHOLD = 0.03;
|
|
1367
|
+
var DEFAULT_PALETTE = [
|
|
1368
|
+
"#1b7fa3",
|
|
1369
|
+
"#c44e52",
|
|
1370
|
+
"#6a9f58",
|
|
1371
|
+
"#d47215",
|
|
1372
|
+
"#507e79",
|
|
1373
|
+
"#9a6a8d",
|
|
1374
|
+
"#c4636b",
|
|
1375
|
+
"#9c755f",
|
|
1376
|
+
"#a88f22",
|
|
1377
|
+
"#858078"
|
|
1378
|
+
];
|
|
1379
|
+
function groupSmallSlices(slices, threshold) {
|
|
1380
|
+
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
|
1381
|
+
if (total === 0) return slices;
|
|
1382
|
+
const big = [];
|
|
1383
|
+
let otherValue = 0;
|
|
1384
|
+
for (const slice of slices) {
|
|
1385
|
+
if (slice.value / total < threshold) {
|
|
1386
|
+
otherValue += slice.value;
|
|
1387
|
+
} else {
|
|
1388
|
+
big.push(slice);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if (otherValue > 0) {
|
|
1392
|
+
big.push({
|
|
1393
|
+
label: "Other",
|
|
1394
|
+
value: otherValue,
|
|
1395
|
+
originalRow: { label: "Other", value: otherValue }
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
return big;
|
|
1399
|
+
}
|
|
1400
|
+
function computePieMarks(spec, scales, chartArea, _strategy, isDonut = false) {
|
|
1401
|
+
const encoding = spec.encoding;
|
|
1402
|
+
const valueChannel = encoding.y ?? encoding.x;
|
|
1403
|
+
const categoryField = encoding.color?.field;
|
|
1404
|
+
if (!valueChannel) return [];
|
|
1405
|
+
let slices = [];
|
|
1406
|
+
if (categoryField) {
|
|
1407
|
+
const categoryTotals = /* @__PURE__ */ new Map();
|
|
1408
|
+
const categoryRows = /* @__PURE__ */ new Map();
|
|
1409
|
+
for (const row of spec.data) {
|
|
1410
|
+
const cat = String(row[categoryField] ?? "");
|
|
1411
|
+
const val = Number(row[valueChannel.field] ?? 0);
|
|
1412
|
+
if (!Number.isFinite(val) || val < 0) continue;
|
|
1413
|
+
categoryTotals.set(cat, (categoryTotals.get(cat) ?? 0) + val);
|
|
1414
|
+
if (!categoryRows.has(cat)) {
|
|
1415
|
+
categoryRows.set(cat, row);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
for (const [label, value] of categoryTotals) {
|
|
1419
|
+
slices.push({
|
|
1420
|
+
label,
|
|
1421
|
+
value,
|
|
1422
|
+
originalRow: categoryRows.get(label) ?? {
|
|
1423
|
+
[categoryField]: label,
|
|
1424
|
+
[valueChannel.field]: value
|
|
1425
|
+
}
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
} else {
|
|
1429
|
+
for (let i = 0; i < spec.data.length; i++) {
|
|
1430
|
+
const row = spec.data[i];
|
|
1431
|
+
const val = Number(row[valueChannel.field] ?? 0);
|
|
1432
|
+
if (!Number.isFinite(val) || val < 0) continue;
|
|
1433
|
+
const label = String(row.label ?? row.name ?? row.category ?? `Slice ${i + 1}`);
|
|
1434
|
+
slices.push({ label, value: val, originalRow: row });
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (slices.length === 0) return [];
|
|
1438
|
+
slices.sort((a, b) => b.value - a.value);
|
|
1439
|
+
slices = groupSmallSlices(slices, SMALL_SLICE_THRESHOLD);
|
|
1440
|
+
const pieGenerator = d3Pie().value((d) => d.value).sort(null).padAngle(0.01);
|
|
1441
|
+
const arcs = pieGenerator(slices);
|
|
1442
|
+
const centerX = chartArea.x + chartArea.width / 2;
|
|
1443
|
+
const centerY = chartArea.y + chartArea.height / 2;
|
|
1444
|
+
const outerRadius = Math.min(chartArea.width, chartArea.height) / 2 * 0.85;
|
|
1445
|
+
const innerRadius = isDonut ? outerRadius * 0.6 : 0;
|
|
1446
|
+
const arcGenerator = d3Arc().innerRadius(innerRadius).outerRadius(outerRadius);
|
|
1447
|
+
const marks = [];
|
|
1448
|
+
const center = { x: centerX, y: centerY };
|
|
1449
|
+
const total = slices.reduce((sum, s) => sum + s.value, 0);
|
|
1450
|
+
for (let i = 0; i < arcs.length; i++) {
|
|
1451
|
+
const arcDatum = arcs[i];
|
|
1452
|
+
const slice = arcDatum.data;
|
|
1453
|
+
let color;
|
|
1454
|
+
if (scales.color && categoryField) {
|
|
1455
|
+
const colorScale = scales.color.scale;
|
|
1456
|
+
color = colorScale(slice.label);
|
|
1457
|
+
} else {
|
|
1458
|
+
color = DEFAULT_PALETTE[i % DEFAULT_PALETTE.length];
|
|
1459
|
+
}
|
|
1460
|
+
const path = arcGenerator(arcDatum) ?? "";
|
|
1461
|
+
const centroidResult = arcGenerator.centroid(arcDatum);
|
|
1462
|
+
const percentage = total > 0 ? (slice.value / total * 100).toFixed(1) : "0";
|
|
1463
|
+
const aria = {
|
|
1464
|
+
label: `${slice.label}: ${slice.value} (${percentage}%)`
|
|
1465
|
+
};
|
|
1466
|
+
marks.push({
|
|
1467
|
+
type: "arc",
|
|
1468
|
+
path,
|
|
1469
|
+
centroid: {
|
|
1470
|
+
x: centroidResult[0] + centerX,
|
|
1471
|
+
y: centroidResult[1] + centerY
|
|
1472
|
+
},
|
|
1473
|
+
center,
|
|
1474
|
+
innerRadius,
|
|
1475
|
+
outerRadius,
|
|
1476
|
+
startAngle: arcDatum.startAngle,
|
|
1477
|
+
endAngle: arcDatum.endAngle,
|
|
1478
|
+
fill: color,
|
|
1479
|
+
stroke: "#ffffff",
|
|
1480
|
+
strokeWidth: 2,
|
|
1481
|
+
data: slice.originalRow,
|
|
1482
|
+
aria
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
return marks;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
// src/charts/pie/labels.ts
|
|
1489
|
+
import { estimateTextWidth as estimateTextWidth6, resolveCollisions as resolveCollisions5 } from "@opendata-ai/openchart-core";
|
|
1490
|
+
var LABEL_FONT_SIZE5 = 10;
|
|
1491
|
+
var LABEL_FONT_WEIGHT5 = 500;
|
|
1492
|
+
var LEADER_LINE_OFFSET = 12;
|
|
1493
|
+
function computePieLabels(marks, _chartArea, density = "auto", _textFill = "#333333") {
|
|
1494
|
+
if (marks.length === 0) return [];
|
|
1495
|
+
if (density === "none") return [];
|
|
1496
|
+
const centerX = marks[0].center.x;
|
|
1497
|
+
const centerY = marks[0].center.y;
|
|
1498
|
+
const targetMarks = density === "endpoints" && marks.length > 1 ? [marks[0], marks[marks.length - 1]] : marks;
|
|
1499
|
+
const candidates = [];
|
|
1500
|
+
const targetMarkIndices = [];
|
|
1501
|
+
for (let mi = 0; mi < targetMarks.length; mi++) {
|
|
1502
|
+
const mark = targetMarks[mi];
|
|
1503
|
+
const ariaLabel = mark.aria.label;
|
|
1504
|
+
const firstColon = ariaLabel.indexOf(":");
|
|
1505
|
+
const labelText = firstColon >= 0 ? ariaLabel.slice(0, firstColon).trim() : "";
|
|
1506
|
+
if (!labelText) continue;
|
|
1507
|
+
const textWidth = estimateTextWidth6(labelText, LABEL_FONT_SIZE5, LABEL_FONT_WEIGHT5);
|
|
1508
|
+
const textHeight = LABEL_FONT_SIZE5 * 1.2;
|
|
1509
|
+
const midAngle = (mark.startAngle + mark.endAngle) / 2;
|
|
1510
|
+
const labelRadius = mark.outerRadius + LEADER_LINE_OFFSET;
|
|
1511
|
+
const labelX = centerX + Math.sin(midAngle) * labelRadius;
|
|
1512
|
+
const labelY = centerY - Math.cos(midAngle) * labelRadius;
|
|
1513
|
+
const isRight = Math.sin(midAngle) > 0;
|
|
1514
|
+
candidates.push({
|
|
1515
|
+
text: labelText,
|
|
1516
|
+
anchorX: isRight ? labelX : labelX - textWidth,
|
|
1517
|
+
anchorY: labelY - textHeight / 2,
|
|
1518
|
+
width: textWidth,
|
|
1519
|
+
height: textHeight,
|
|
1520
|
+
priority: "data",
|
|
1521
|
+
style: {
|
|
1522
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
1523
|
+
fontSize: LABEL_FONT_SIZE5,
|
|
1524
|
+
fontWeight: LABEL_FONT_WEIGHT5,
|
|
1525
|
+
fill: _textFill,
|
|
1526
|
+
lineHeight: 1.2,
|
|
1527
|
+
textAnchor: isRight ? "start" : "end",
|
|
1528
|
+
dominantBaseline: "central"
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
targetMarkIndices.push(mi);
|
|
1532
|
+
}
|
|
1533
|
+
if (candidates.length === 0) return [];
|
|
1534
|
+
let resolved;
|
|
1535
|
+
if (density === "all") {
|
|
1536
|
+
resolved = candidates.map((c) => ({
|
|
1537
|
+
text: c.text,
|
|
1538
|
+
x: c.anchorX,
|
|
1539
|
+
y: c.anchorY,
|
|
1540
|
+
style: c.style,
|
|
1541
|
+
visible: true
|
|
1542
|
+
}));
|
|
1543
|
+
} else {
|
|
1544
|
+
resolved = resolveCollisions5(candidates);
|
|
1545
|
+
}
|
|
1546
|
+
for (let i = 0; i < resolved.length && i < targetMarks.length; i++) {
|
|
1547
|
+
const label = resolved[i];
|
|
1548
|
+
const mark = targetMarks[i];
|
|
1549
|
+
if (label.visible) {
|
|
1550
|
+
label.connector = {
|
|
1551
|
+
from: { x: label.x, y: label.y },
|
|
1552
|
+
to: { x: mark.centroid.x, y: mark.centroid.y },
|
|
1553
|
+
stroke: _textFill,
|
|
1554
|
+
style: "straight"
|
|
1555
|
+
};
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
return resolved;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// src/charts/pie/index.ts
|
|
1562
|
+
var pieRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
1563
|
+
const marks = computePieMarks(spec, scales, chartArea, strategy, false);
|
|
1564
|
+
const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
|
|
1565
|
+
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
1566
|
+
marks[i].label = labels[i];
|
|
1567
|
+
}
|
|
1568
|
+
return marks;
|
|
1569
|
+
};
|
|
1570
|
+
var donutRenderer = (spec, scales, chartArea, strategy, theme) => {
|
|
1571
|
+
const marks = computePieMarks(spec, scales, chartArea, strategy, true);
|
|
1572
|
+
const labels = computePieLabels(marks, chartArea, spec.labels.density, theme.colors.text);
|
|
1573
|
+
for (let i = 0; i < marks.length && i < labels.length; i++) {
|
|
1574
|
+
marks[i].label = labels[i];
|
|
1575
|
+
}
|
|
1576
|
+
return marks;
|
|
1577
|
+
};
|
|
1578
|
+
|
|
1579
|
+
// src/charts/registry.ts
|
|
1580
|
+
var renderers = /* @__PURE__ */ new Map();
|
|
1581
|
+
function registerChartRenderer(type, renderer) {
|
|
1582
|
+
renderers.set(type, renderer);
|
|
1583
|
+
}
|
|
1584
|
+
function getChartRenderer(type) {
|
|
1585
|
+
return renderers.get(type);
|
|
1586
|
+
}
|
|
1587
|
+
function clearRenderers() {
|
|
1588
|
+
renderers.clear();
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// src/charts/scatter/compute.ts
|
|
1592
|
+
import { max, min } from "d3-array";
|
|
1593
|
+
import { scaleSqrt } from "d3-scale";
|
|
1594
|
+
var DEFAULT_POINT_RADIUS2 = 5;
|
|
1595
|
+
var MIN_BUBBLE_RADIUS = 3;
|
|
1596
|
+
var MAX_BUBBLE_RADIUS = 30;
|
|
1597
|
+
function computeScatterMarks(spec, scales, _chartArea, _strategy) {
|
|
1598
|
+
const encoding = spec.encoding;
|
|
1599
|
+
const xChannel = encoding.x;
|
|
1600
|
+
const yChannel = encoding.y;
|
|
1601
|
+
if (!xChannel || !yChannel || !scales.x || !scales.y) {
|
|
1602
|
+
return [];
|
|
1603
|
+
}
|
|
1604
|
+
const xScale = scales.x.scale;
|
|
1605
|
+
const yScale = scales.y.scale;
|
|
1606
|
+
const colorField = encoding.color?.field;
|
|
1607
|
+
const sizeField = encoding.size?.field;
|
|
1608
|
+
let sizeScale;
|
|
1609
|
+
if (sizeField) {
|
|
1610
|
+
const sizeValues = spec.data.map((d) => Number(d[sizeField])).filter((v) => Number.isFinite(v));
|
|
1611
|
+
const sizeMin = min(sizeValues) ?? 0;
|
|
1612
|
+
const sizeMax = max(sizeValues) ?? 1;
|
|
1613
|
+
sizeScale = scaleSqrt().domain([sizeMin, sizeMax]).range([MIN_BUBBLE_RADIUS, MAX_BUBBLE_RADIUS]);
|
|
1614
|
+
}
|
|
1615
|
+
const marks = [];
|
|
1616
|
+
for (const row of spec.data) {
|
|
1617
|
+
const xVal = Number(row[xChannel.field]);
|
|
1618
|
+
const yVal = Number(row[yChannel.field]);
|
|
1619
|
+
if (!Number.isFinite(xVal) || !Number.isFinite(yVal)) continue;
|
|
1620
|
+
const cx = xScale(xVal);
|
|
1621
|
+
const cy = yScale(yVal);
|
|
1622
|
+
const category = colorField ? String(row[colorField] ?? "") : void 0;
|
|
1623
|
+
const color = getColor(scales, category ?? "__default__");
|
|
1624
|
+
let radius = DEFAULT_POINT_RADIUS2;
|
|
1625
|
+
if (sizeScale && sizeField) {
|
|
1626
|
+
const sizeVal = Number(row[sizeField]);
|
|
1627
|
+
if (Number.isFinite(sizeVal)) {
|
|
1628
|
+
radius = sizeScale(sizeVal);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
const labelParts = [`${xChannel.field}=${xVal}`, `${yChannel.field}=${yVal}`];
|
|
1632
|
+
if (category) labelParts.push(`${colorField}=${category}`);
|
|
1633
|
+
if (sizeField && row[sizeField] != null) {
|
|
1634
|
+
labelParts.push(`${sizeField}=${row[sizeField]}`);
|
|
1635
|
+
}
|
|
1636
|
+
const aria = {
|
|
1637
|
+
label: `Data point: ${labelParts.join(", ")}`
|
|
1638
|
+
};
|
|
1639
|
+
marks.push({
|
|
1640
|
+
type: "point",
|
|
1641
|
+
cx,
|
|
1642
|
+
cy,
|
|
1643
|
+
r: radius,
|
|
1644
|
+
fill: color,
|
|
1645
|
+
stroke: "#ffffff",
|
|
1646
|
+
strokeWidth: 1,
|
|
1647
|
+
fillOpacity: 0.7,
|
|
1648
|
+
data: row,
|
|
1649
|
+
aria
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
return marks;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/charts/scatter/trendline.ts
|
|
1656
|
+
var TRENDLINE_COLOR = "#666666";
|
|
1657
|
+
var TRENDLINE_STROKE_WIDTH = 1.5;
|
|
1658
|
+
var TRENDLINE_DASH = "6 4";
|
|
1659
|
+
function linearRegression(points) {
|
|
1660
|
+
const n = points.length;
|
|
1661
|
+
if (n < 2) return null;
|
|
1662
|
+
let sumX = 0;
|
|
1663
|
+
let sumY = 0;
|
|
1664
|
+
let sumXY = 0;
|
|
1665
|
+
let sumXX = 0;
|
|
1666
|
+
for (const p of points) {
|
|
1667
|
+
sumX += p.x;
|
|
1668
|
+
sumY += p.y;
|
|
1669
|
+
sumXY += p.x * p.y;
|
|
1670
|
+
sumXX += p.x * p.x;
|
|
1671
|
+
}
|
|
1672
|
+
const denominator = n * sumXX - sumX * sumX;
|
|
1673
|
+
if (denominator === 0) return null;
|
|
1674
|
+
const slope = (n * sumXY - sumX * sumY) / denominator;
|
|
1675
|
+
const intercept = (sumY - slope * sumX) / n;
|
|
1676
|
+
return { slope, intercept };
|
|
1677
|
+
}
|
|
1678
|
+
function computeTrendLine(marks) {
|
|
1679
|
+
if (marks.length < 2) return null;
|
|
1680
|
+
const points = marks.map((m) => ({ x: m.cx, y: m.cy }));
|
|
1681
|
+
const result = linearRegression(points);
|
|
1682
|
+
if (!result) return null;
|
|
1683
|
+
const { slope, intercept } = result;
|
|
1684
|
+
let minX = Infinity;
|
|
1685
|
+
let maxX = -Infinity;
|
|
1686
|
+
for (const m of marks) {
|
|
1687
|
+
if (m.cx < minX) minX = m.cx;
|
|
1688
|
+
if (m.cx > maxX) maxX = m.cx;
|
|
1689
|
+
}
|
|
1690
|
+
const y1 = slope * minX + intercept;
|
|
1691
|
+
const y2 = slope * maxX + intercept;
|
|
1692
|
+
const aria = {
|
|
1693
|
+
label: `Trend line: linear regression`
|
|
1694
|
+
};
|
|
1695
|
+
return {
|
|
1696
|
+
type: "line",
|
|
1697
|
+
points: [
|
|
1698
|
+
{ x: minX, y: y1 },
|
|
1699
|
+
{ x: maxX, y: y2 }
|
|
1700
|
+
],
|
|
1701
|
+
stroke: TRENDLINE_COLOR,
|
|
1702
|
+
strokeWidth: TRENDLINE_STROKE_WIDTH,
|
|
1703
|
+
strokeDasharray: TRENDLINE_DASH,
|
|
1704
|
+
data: [],
|
|
1705
|
+
aria
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// src/charts/scatter/index.ts
|
|
1710
|
+
var scatterRenderer = (spec, scales, chartArea, strategy, _theme) => {
|
|
1711
|
+
const pointMarks = computeScatterMarks(spec, scales, chartArea, strategy);
|
|
1712
|
+
const marks = [...pointMarks];
|
|
1713
|
+
const trendLine = computeTrendLine(pointMarks);
|
|
1714
|
+
if (trendLine) {
|
|
1715
|
+
marks.unshift(trendLine);
|
|
1716
|
+
}
|
|
1717
|
+
return marks;
|
|
1718
|
+
};
|
|
1719
|
+
|
|
1720
|
+
// src/compiler/normalize.ts
|
|
1721
|
+
import { isChartSpec, isGraphSpec, isTableSpec } from "@opendata-ai/openchart-core";
|
|
1722
|
+
function normalizeChromeField(value) {
|
|
1723
|
+
if (value === void 0) return void 0;
|
|
1724
|
+
if (typeof value === "string") return { text: value };
|
|
1725
|
+
return value;
|
|
1726
|
+
}
|
|
1727
|
+
function normalizeChrome(chrome) {
|
|
1728
|
+
if (!chrome) return {};
|
|
1729
|
+
return {
|
|
1730
|
+
title: normalizeChromeField(chrome.title),
|
|
1731
|
+
subtitle: normalizeChromeField(chrome.subtitle),
|
|
1732
|
+
source: normalizeChromeField(chrome.source),
|
|
1733
|
+
byline: normalizeChromeField(chrome.byline),
|
|
1734
|
+
footer: normalizeChromeField(chrome.footer)
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
function inferFieldType(data, field) {
|
|
1738
|
+
const sampleSize = Math.min(10, data.length);
|
|
1739
|
+
let numericCount = 0;
|
|
1740
|
+
let dateCount = 0;
|
|
1741
|
+
let totalNonNull = 0;
|
|
1742
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
1743
|
+
const value = data[i][field];
|
|
1744
|
+
if (value == null) continue;
|
|
1745
|
+
totalNonNull++;
|
|
1746
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1747
|
+
numericCount++;
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (typeof value === "string") {
|
|
1751
|
+
const num = Number(value);
|
|
1752
|
+
if (!Number.isNaN(num) && Number.isFinite(num) && value.trim() !== "") {
|
|
1753
|
+
numericCount++;
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
const date = new Date(value);
|
|
1757
|
+
if (!Number.isNaN(date.getTime())) {
|
|
1758
|
+
dateCount++;
|
|
1759
|
+
continue;
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) {
|
|
1763
|
+
dateCount++;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
if (totalNonNull === 0) return "nominal";
|
|
1767
|
+
if (dateCount / totalNonNull > 0.8) return "temporal";
|
|
1768
|
+
if (numericCount / totalNonNull > 0.8) return "quantitative";
|
|
1769
|
+
return "nominal";
|
|
1770
|
+
}
|
|
1771
|
+
function inferEncodingTypes(encoding, data, warnings) {
|
|
1772
|
+
const result = { ...encoding };
|
|
1773
|
+
for (const channel of ["x", "y", "color", "size", "detail"]) {
|
|
1774
|
+
const spec = result[channel];
|
|
1775
|
+
if (!spec) continue;
|
|
1776
|
+
if (!spec.type) {
|
|
1777
|
+
const inferred = inferFieldType(data, spec.field);
|
|
1778
|
+
result[channel] = { ...spec, type: inferred };
|
|
1779
|
+
warnings.push(
|
|
1780
|
+
`Inferred encoding.${channel}.type as "${inferred}" from data values for field "${spec.field}"`
|
|
1781
|
+
);
|
|
1782
|
+
} else {
|
|
1783
|
+
const actualType = inferFieldType(data, spec.field);
|
|
1784
|
+
if (spec.type === "nominal" && actualType === "temporal") {
|
|
1785
|
+
warnings.push(`Field "${spec.field}" looks temporal but was declared as nominal`);
|
|
1786
|
+
}
|
|
1787
|
+
if (spec.type === "nominal" && actualType === "quantitative") {
|
|
1788
|
+
warnings.push(`Field "${spec.field}" looks quantitative but was declared as nominal`);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
return result;
|
|
1793
|
+
}
|
|
1794
|
+
function normalizeAnnotations(annotations) {
|
|
1795
|
+
if (!annotations || annotations.length === 0) return [];
|
|
1796
|
+
return annotations.map((ann) => {
|
|
1797
|
+
switch (ann.type) {
|
|
1798
|
+
case "text":
|
|
1799
|
+
return {
|
|
1800
|
+
...ann,
|
|
1801
|
+
fontSize: ann.fontSize ?? 12,
|
|
1802
|
+
fontWeight: ann.fontWeight ?? 400,
|
|
1803
|
+
opacity: ann.opacity ?? 1
|
|
1804
|
+
};
|
|
1805
|
+
case "range":
|
|
1806
|
+
return {
|
|
1807
|
+
...ann,
|
|
1808
|
+
opacity: ann.opacity ?? 0.1,
|
|
1809
|
+
fill: ann.fill ?? "#000000"
|
|
1810
|
+
};
|
|
1811
|
+
case "refline":
|
|
1812
|
+
return {
|
|
1813
|
+
...ann,
|
|
1814
|
+
style: ann.style ?? "dashed",
|
|
1815
|
+
strokeWidth: ann.strokeWidth ?? 1,
|
|
1816
|
+
stroke: ann.stroke ?? "#666666",
|
|
1817
|
+
opacity: ann.opacity ?? 0.8
|
|
1818
|
+
};
|
|
1819
|
+
default:
|
|
1820
|
+
return ann;
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
function normalizeChartSpec(spec, warnings) {
|
|
1825
|
+
const encoding = inferEncodingTypes(spec.encoding, spec.data, warnings);
|
|
1826
|
+
return {
|
|
1827
|
+
type: spec.type,
|
|
1828
|
+
data: spec.data,
|
|
1829
|
+
encoding,
|
|
1830
|
+
chrome: normalizeChrome(spec.chrome),
|
|
1831
|
+
annotations: normalizeAnnotations(spec.annotations),
|
|
1832
|
+
labels: {
|
|
1833
|
+
density: spec.labels?.density ?? "auto",
|
|
1834
|
+
format: spec.labels?.format ?? "",
|
|
1835
|
+
offsets: spec.labels?.offsets
|
|
1836
|
+
},
|
|
1837
|
+
legend: spec.legend,
|
|
1838
|
+
responsive: spec.responsive ?? true,
|
|
1839
|
+
theme: spec.theme ?? {},
|
|
1840
|
+
darkMode: spec.darkMode ?? "off"
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
function normalizeTableSpec(spec, _warnings) {
|
|
1844
|
+
return {
|
|
1845
|
+
type: "table",
|
|
1846
|
+
data: spec.data,
|
|
1847
|
+
columns: spec.columns,
|
|
1848
|
+
rowKey: spec.rowKey,
|
|
1849
|
+
chrome: normalizeChrome(spec.chrome),
|
|
1850
|
+
theme: spec.theme ?? {},
|
|
1851
|
+
darkMode: spec.darkMode ?? "off",
|
|
1852
|
+
search: spec.search ?? false,
|
|
1853
|
+
pagination: spec.pagination ?? false,
|
|
1854
|
+
stickyFirstColumn: spec.stickyFirstColumn ?? false,
|
|
1855
|
+
compact: spec.compact ?? false,
|
|
1856
|
+
responsive: spec.responsive ?? true
|
|
1857
|
+
};
|
|
1858
|
+
}
|
|
1859
|
+
function normalizeGraphSpec(spec, _warnings) {
|
|
1860
|
+
const defaultLayout = {
|
|
1861
|
+
type: "force",
|
|
1862
|
+
chargeStrength: -300,
|
|
1863
|
+
linkDistance: 30
|
|
1864
|
+
};
|
|
1865
|
+
const layout = spec.layout ? {
|
|
1866
|
+
...defaultLayout,
|
|
1867
|
+
...spec.layout
|
|
1868
|
+
} : defaultLayout;
|
|
1869
|
+
return {
|
|
1870
|
+
type: "graph",
|
|
1871
|
+
nodes: spec.nodes,
|
|
1872
|
+
edges: spec.edges,
|
|
1873
|
+
encoding: spec.encoding ?? {},
|
|
1874
|
+
layout,
|
|
1875
|
+
chrome: normalizeChrome(spec.chrome),
|
|
1876
|
+
annotations: normalizeAnnotations(spec.annotations),
|
|
1877
|
+
theme: spec.theme ?? {},
|
|
1878
|
+
darkMode: spec.darkMode ?? "off"
|
|
1879
|
+
};
|
|
1880
|
+
}
|
|
1881
|
+
function normalizeSpec(spec, warnings = []) {
|
|
1882
|
+
if (isChartSpec(spec)) {
|
|
1883
|
+
return normalizeChartSpec(spec, warnings);
|
|
1884
|
+
}
|
|
1885
|
+
if (isTableSpec(spec)) {
|
|
1886
|
+
return normalizeTableSpec(spec, warnings);
|
|
1887
|
+
}
|
|
1888
|
+
if (isGraphSpec(spec)) {
|
|
1889
|
+
return normalizeGraphSpec(spec, warnings);
|
|
1890
|
+
}
|
|
1891
|
+
throw new Error(`Unknown spec type: ${spec.type}`);
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
// src/compiler/validate.ts
|
|
1895
|
+
import {
|
|
1896
|
+
CHART_ENCODING_RULES,
|
|
1897
|
+
CHART_TYPES
|
|
1898
|
+
} from "@opendata-ai/openchart-core";
|
|
1899
|
+
var VALID_FIELD_TYPES = /* @__PURE__ */ new Set(["quantitative", "temporal", "nominal", "ordinal"]);
|
|
1900
|
+
var VALID_DARK_MODES = /* @__PURE__ */ new Set(["auto", "force", "off"]);
|
|
1901
|
+
function isParseableDate(value) {
|
|
1902
|
+
if (value instanceof Date) return !Number.isNaN(value.getTime());
|
|
1903
|
+
if (typeof value === "string") {
|
|
1904
|
+
const d = new Date(value);
|
|
1905
|
+
return !Number.isNaN(d.getTime());
|
|
1906
|
+
}
|
|
1907
|
+
if (typeof value === "number") return true;
|
|
1908
|
+
return false;
|
|
1909
|
+
}
|
|
1910
|
+
function isNumeric(value) {
|
|
1911
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
1912
|
+
if (typeof value === "string") {
|
|
1913
|
+
const n = Number(value);
|
|
1914
|
+
return !Number.isNaN(n) && Number.isFinite(n);
|
|
1915
|
+
}
|
|
1916
|
+
return false;
|
|
1917
|
+
}
|
|
1918
|
+
function validateChartSpec(spec, errors) {
|
|
1919
|
+
const chartType = spec.type;
|
|
1920
|
+
if (!Array.isArray(spec.data)) {
|
|
1921
|
+
errors.push({
|
|
1922
|
+
message: 'Spec error: "data" must be an array',
|
|
1923
|
+
path: "data",
|
|
1924
|
+
code: "INVALID_TYPE",
|
|
1925
|
+
suggestion: "Provide data as an array of objects, e.g. data: [{ x: 1, y: 2 }]"
|
|
1926
|
+
});
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
if (spec.data.length === 0) {
|
|
1930
|
+
errors.push({
|
|
1931
|
+
message: 'Spec error: "data" must be a non-empty array',
|
|
1932
|
+
path: "data",
|
|
1933
|
+
code: "EMPTY_DATA",
|
|
1934
|
+
suggestion: "Add at least one data row, e.g. data: [{ x: 1, y: 2 }]"
|
|
1935
|
+
});
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
const firstRow = spec.data[0];
|
|
1939
|
+
if (typeof firstRow !== "object" || firstRow === null || Array.isArray(firstRow)) {
|
|
1940
|
+
errors.push({
|
|
1941
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
1942
|
+
path: "data[0]",
|
|
1943
|
+
code: "INVALID_TYPE",
|
|
1944
|
+
suggestion: 'Each data item should be an object with key-value pairs, e.g. { name: "Alice", value: 10 }'
|
|
1945
|
+
});
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
if (!spec.encoding || typeof spec.encoding !== "object") {
|
|
1949
|
+
const rules2 = CHART_ENCODING_RULES[chartType];
|
|
1950
|
+
const requiredChannels = Object.entries(rules2).filter(([, rule]) => rule.required).map(([ch]) => ch);
|
|
1951
|
+
errors.push({
|
|
1952
|
+
message: `Spec error: ${chartType} chart requires an "encoding" object`,
|
|
1953
|
+
path: "encoding",
|
|
1954
|
+
code: "MISSING_FIELD",
|
|
1955
|
+
suggestion: `Add an encoding object with required channels: ${requiredChannels.join(", ")}. Example: encoding: { ${requiredChannels.map((ch) => `${ch}: { field: "...", type: "..." }`).join(", ")} }`
|
|
1956
|
+
});
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const rules = CHART_ENCODING_RULES[chartType];
|
|
1960
|
+
const encoding = spec.encoding;
|
|
1961
|
+
const dataColumns = new Set(Object.keys(firstRow));
|
|
1962
|
+
const availableColumns = [...dataColumns].join(", ");
|
|
1963
|
+
for (const [channel, rule] of Object.entries(rules)) {
|
|
1964
|
+
if (rule.required && !encoding[channel]) {
|
|
1965
|
+
const allowedTypes = rule.allowedTypes.join(" or ");
|
|
1966
|
+
errors.push({
|
|
1967
|
+
message: `Spec error: ${chartType} chart requires encoding.${channel} but none was provided`,
|
|
1968
|
+
path: `encoding.${channel}`,
|
|
1969
|
+
code: "MISSING_FIELD",
|
|
1970
|
+
suggestion: `Add encoding.${channel} with a field from your data (${availableColumns}) and type (${allowedTypes}). Example: ${channel}: { field: "${[...dataColumns][0] ?? "myField"}", type: "${rule.allowedTypes[0]}" }`
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
for (const [channel, channelSpec] of Object.entries(encoding)) {
|
|
1975
|
+
if (!channelSpec || typeof channelSpec !== "object") continue;
|
|
1976
|
+
const channelObj = channelSpec;
|
|
1977
|
+
const channelRule = rules[channel];
|
|
1978
|
+
if (!channelObj.field || typeof channelObj.field !== "string") {
|
|
1979
|
+
errors.push({
|
|
1980
|
+
message: `Spec error: encoding.${channel} must have a "field" string`,
|
|
1981
|
+
path: `encoding.${channel}.field`,
|
|
1982
|
+
code: "MISSING_FIELD",
|
|
1983
|
+
suggestion: `Add a field name from your data columns: ${availableColumns}`
|
|
1984
|
+
});
|
|
1985
|
+
continue;
|
|
1986
|
+
}
|
|
1987
|
+
if (!dataColumns.has(channelObj.field)) {
|
|
1988
|
+
errors.push({
|
|
1989
|
+
message: `Spec error: encoding.${channel}.field "${channelObj.field}" does not exist in data. Available columns: ${availableColumns}`,
|
|
1990
|
+
path: `encoding.${channel}.field`,
|
|
1991
|
+
code: "DATA_FIELD_MISSING",
|
|
1992
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
if (channelObj.type && !VALID_FIELD_TYPES.has(channelObj.type)) {
|
|
1996
|
+
errors.push({
|
|
1997
|
+
message: `Spec error: encoding.${channel}.type "${channelObj.type}" is not valid. Must be one of: ${[...VALID_FIELD_TYPES].join(", ")}`,
|
|
1998
|
+
path: `encoding.${channel}.type`,
|
|
1999
|
+
code: "INVALID_VALUE",
|
|
2000
|
+
suggestion: `Use one of: ${[...VALID_FIELD_TYPES].join(", ")}`
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
if (channelRule && channelObj.type && channelRule.allowedTypes.length > 0) {
|
|
2004
|
+
if (!channelRule.allowedTypes.includes(channelObj.type)) {
|
|
2005
|
+
errors.push({
|
|
2006
|
+
message: `Spec error: encoding.${channel} for ${chartType} chart does not accept type "${channelObj.type}". Allowed types: ${channelRule.allowedTypes.join(", ")}`,
|
|
2007
|
+
path: `encoding.${channel}.type`,
|
|
2008
|
+
code: "ENCODING_MISMATCH",
|
|
2009
|
+
suggestion: `Change encoding.${channel}.type to one of: ${channelRule.allowedTypes.join(", ")}`
|
|
2010
|
+
});
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
if (channelObj.type && channelObj.field && dataColumns.has(channelObj.field)) {
|
|
2014
|
+
const data = spec.data;
|
|
2015
|
+
const fieldName = channelObj.field;
|
|
2016
|
+
const fieldType = channelObj.type;
|
|
2017
|
+
const sampleSize = Math.min(5, data.length);
|
|
2018
|
+
if (fieldType === "temporal") {
|
|
2019
|
+
let nonDateCount = 0;
|
|
2020
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
2021
|
+
const val = data[i][fieldName];
|
|
2022
|
+
if (val != null && !isParseableDate(val)) {
|
|
2023
|
+
nonDateCount++;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
if (nonDateCount > 0) {
|
|
2027
|
+
errors.push({
|
|
2028
|
+
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as temporal but contains non-date values`,
|
|
2029
|
+
path: `encoding.${channel}`,
|
|
2030
|
+
code: "ENCODING_MISMATCH",
|
|
2031
|
+
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are parseable dates (ISO 8601 strings like "2024-01-15" or Date objects)`
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (fieldType === "quantitative") {
|
|
2036
|
+
let nonNumericCount = 0;
|
|
2037
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
2038
|
+
const val = data[i][fieldName];
|
|
2039
|
+
if (val != null && !isNumeric(val)) {
|
|
2040
|
+
nonNumericCount++;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
if (nonNumericCount > 0) {
|
|
2044
|
+
errors.push({
|
|
2045
|
+
message: `Spec error: encoding.${channel}.field "${fieldName}" is declared as quantitative but contains non-numeric values`,
|
|
2046
|
+
path: `encoding.${channel}`,
|
|
2047
|
+
code: "ENCODING_MISMATCH",
|
|
2048
|
+
suggestion: `Either change the type to "nominal" or ensure "${fieldName}" values are numbers`
|
|
2049
|
+
});
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
}
|
|
2054
|
+
if (spec.darkMode !== void 0 && !VALID_DARK_MODES.has(spec.darkMode)) {
|
|
2055
|
+
errors.push({
|
|
2056
|
+
message: `Spec error: darkMode must be "auto", "force", or "off"`,
|
|
2057
|
+
path: "darkMode",
|
|
2058
|
+
code: "INVALID_VALUE",
|
|
2059
|
+
suggestion: 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)'
|
|
2060
|
+
});
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
function validateTableSpec(spec, errors) {
|
|
2064
|
+
if (!Array.isArray(spec.data)) {
|
|
2065
|
+
errors.push({
|
|
2066
|
+
message: 'Spec error: "data" must be an array',
|
|
2067
|
+
path: "data",
|
|
2068
|
+
code: "INVALID_TYPE",
|
|
2069
|
+
suggestion: 'Provide data as an array of objects, e.g. data: [{ name: "Alice", age: 30 }]'
|
|
2070
|
+
});
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (spec.data.length === 0) {
|
|
2074
|
+
errors.push({
|
|
2075
|
+
message: 'Spec error: "data" must be a non-empty array',
|
|
2076
|
+
path: "data",
|
|
2077
|
+
code: "EMPTY_DATA",
|
|
2078
|
+
suggestion: "Add at least one data row to the data array"
|
|
2079
|
+
});
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
if (!Array.isArray(spec.columns)) {
|
|
2083
|
+
errors.push({
|
|
2084
|
+
message: 'Spec error: table spec requires a "columns" array',
|
|
2085
|
+
path: "columns",
|
|
2086
|
+
code: "MISSING_FIELD",
|
|
2087
|
+
suggestion: 'Add a columns array defining which data fields to display, e.g. columns: [{ key: "name" }, { key: "age" }]'
|
|
2088
|
+
});
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const data = spec.data;
|
|
2092
|
+
const firstRow = data[0];
|
|
2093
|
+
if (typeof firstRow !== "object" || firstRow === null || Array.isArray(firstRow)) {
|
|
2094
|
+
errors.push({
|
|
2095
|
+
message: 'Spec error: each item in "data" must be a plain object',
|
|
2096
|
+
path: "data[0]",
|
|
2097
|
+
code: "INVALID_TYPE",
|
|
2098
|
+
suggestion: 'Each data item should be an object with key-value pairs, e.g. { name: "Alice", age: 30 }'
|
|
2099
|
+
});
|
|
2100
|
+
return;
|
|
2101
|
+
}
|
|
2102
|
+
const dataColumns = new Set(Object.keys(firstRow));
|
|
2103
|
+
const availableColumns = [...dataColumns].join(", ");
|
|
2104
|
+
const columns = spec.columns;
|
|
2105
|
+
for (let i = 0; i < columns.length; i++) {
|
|
2106
|
+
const col = columns[i];
|
|
2107
|
+
if (!col || typeof col !== "object") {
|
|
2108
|
+
errors.push({
|
|
2109
|
+
message: `Spec error: columns[${i}] must be an object`,
|
|
2110
|
+
path: `columns[${i}]`,
|
|
2111
|
+
code: "INVALID_TYPE",
|
|
2112
|
+
suggestion: 'Each column entry should be an object, e.g. { key: "fieldName" }'
|
|
2113
|
+
});
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
if (!col.key || typeof col.key !== "string") {
|
|
2117
|
+
errors.push({
|
|
2118
|
+
message: `Spec error: columns[${i}] must have a "key" string`,
|
|
2119
|
+
path: `columns[${i}].key`,
|
|
2120
|
+
code: "MISSING_FIELD",
|
|
2121
|
+
suggestion: `Add a key referencing a data field. Available columns: ${availableColumns}`
|
|
2122
|
+
});
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (!dataColumns.has(col.key)) {
|
|
2126
|
+
errors.push({
|
|
2127
|
+
message: `Spec error: columns[${i}].key "${col.key}" does not exist in data. Available columns: ${availableColumns}`,
|
|
2128
|
+
path: `columns[${i}].key`,
|
|
2129
|
+
code: "DATA_FIELD_MISSING",
|
|
2130
|
+
suggestion: `Use one of the available data columns: ${availableColumns}`
|
|
2131
|
+
});
|
|
2132
|
+
}
|
|
2133
|
+
const visuals = ["heatmap", "bar", "sparkline", "image", "flag", "categoryColors"].filter(
|
|
2134
|
+
(v) => col[v] != null && col[v] !== false
|
|
2135
|
+
);
|
|
2136
|
+
if (visuals.length > 1) {
|
|
2137
|
+
errors.push({
|
|
2138
|
+
message: `Spec error: columns[${i}] has multiple visual features (${visuals.join(", ")}). Only one is allowed per column.`,
|
|
2139
|
+
path: `columns[${i}]`,
|
|
2140
|
+
code: "INVALID_VALUE",
|
|
2141
|
+
suggestion: `Keep only one visual feature per column. Remove all but one of: ${visuals.join(", ")}`
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
function validateGraphSpec(spec, errors) {
|
|
2147
|
+
if (!Array.isArray(spec.nodes)) {
|
|
2148
|
+
errors.push({
|
|
2149
|
+
message: 'Spec error: graph spec requires a "nodes" array',
|
|
2150
|
+
path: "nodes",
|
|
2151
|
+
code: "MISSING_FIELD",
|
|
2152
|
+
suggestion: 'Add a nodes array with objects that have an "id" field, e.g. nodes: [{ id: "a" }, { id: "b" }]'
|
|
2153
|
+
});
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (spec.nodes.length === 0) {
|
|
2157
|
+
errors.push({
|
|
2158
|
+
message: 'Spec error: "nodes" must be a non-empty array',
|
|
2159
|
+
path: "nodes",
|
|
2160
|
+
code: "EMPTY_DATA",
|
|
2161
|
+
suggestion: 'Add at least one node, e.g. nodes: [{ id: "a" }]'
|
|
2162
|
+
});
|
|
2163
|
+
return;
|
|
2164
|
+
}
|
|
2165
|
+
const nodeIds = /* @__PURE__ */ new Set();
|
|
2166
|
+
const nodes = spec.nodes;
|
|
2167
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
2168
|
+
const node = nodes[i];
|
|
2169
|
+
if (!node || typeof node !== "object") {
|
|
2170
|
+
errors.push({
|
|
2171
|
+
message: `Spec error: nodes[${i}] must be an object`,
|
|
2172
|
+
path: `nodes[${i}]`,
|
|
2173
|
+
code: "INVALID_TYPE",
|
|
2174
|
+
suggestion: 'Each node must be an object with at least an "id" field, e.g. { id: "a" }'
|
|
2175
|
+
});
|
|
2176
|
+
continue;
|
|
2177
|
+
}
|
|
2178
|
+
if (typeof node.id !== "string" || node.id === "") {
|
|
2179
|
+
errors.push({
|
|
2180
|
+
message: `Spec error: nodes[${i}] must have a non-empty string "id" field`,
|
|
2181
|
+
path: `nodes[${i}].id`,
|
|
2182
|
+
code: "MISSING_FIELD",
|
|
2183
|
+
suggestion: 'Add a string id to the node, e.g. { id: "node1" }'
|
|
2184
|
+
});
|
|
2185
|
+
} else {
|
|
2186
|
+
nodeIds.add(node.id);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
if (!Array.isArray(spec.edges)) {
|
|
2190
|
+
errors.push({
|
|
2191
|
+
message: 'Spec error: graph spec requires an "edges" array',
|
|
2192
|
+
path: "edges",
|
|
2193
|
+
code: "MISSING_FIELD",
|
|
2194
|
+
suggestion: 'Add an edges array (can be empty), e.g. edges: [{ source: "a", target: "b" }]'
|
|
2195
|
+
});
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const edges = spec.edges;
|
|
2199
|
+
for (let i = 0; i < edges.length; i++) {
|
|
2200
|
+
const edge = edges[i];
|
|
2201
|
+
if (!edge || typeof edge !== "object") {
|
|
2202
|
+
errors.push({
|
|
2203
|
+
message: `Spec error: edges[${i}] must be an object`,
|
|
2204
|
+
path: `edges[${i}]`,
|
|
2205
|
+
code: "INVALID_TYPE",
|
|
2206
|
+
suggestion: 'Each edge must be an object with "source" and "target" fields, e.g. { source: "a", target: "b" }'
|
|
2207
|
+
});
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
if (typeof edge.source !== "string" || edge.source === "") {
|
|
2211
|
+
errors.push({
|
|
2212
|
+
message: `Spec error: edges[${i}] must have a non-empty string "source" field`,
|
|
2213
|
+
path: `edges[${i}].source`,
|
|
2214
|
+
code: "MISSING_FIELD",
|
|
2215
|
+
suggestion: 'Add a source node id, e.g. { source: "a", target: "b" }'
|
|
2216
|
+
});
|
|
2217
|
+
} else if (nodeIds.size > 0 && !nodeIds.has(edge.source)) {
|
|
2218
|
+
errors.push({
|
|
2219
|
+
message: `Spec error: edges[${i}].source "${edge.source}" does not reference an existing node id`,
|
|
2220
|
+
path: `edges[${i}].source`,
|
|
2221
|
+
code: "DATA_FIELD_MISSING",
|
|
2222
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}`
|
|
2223
|
+
});
|
|
2224
|
+
}
|
|
2225
|
+
if (typeof edge.target !== "string" || edge.target === "") {
|
|
2226
|
+
errors.push({
|
|
2227
|
+
message: `Spec error: edges[${i}] must have a non-empty string "target" field`,
|
|
2228
|
+
path: `edges[${i}].target`,
|
|
2229
|
+
code: "MISSING_FIELD",
|
|
2230
|
+
suggestion: 'Add a target node id, e.g. { source: "a", target: "b" }'
|
|
2231
|
+
});
|
|
2232
|
+
} else if (nodeIds.size > 0 && !nodeIds.has(edge.target)) {
|
|
2233
|
+
errors.push({
|
|
2234
|
+
message: `Spec error: edges[${i}].target "${edge.target}" does not reference an existing node id`,
|
|
2235
|
+
path: `edges[${i}].target`,
|
|
2236
|
+
code: "DATA_FIELD_MISSING",
|
|
2237
|
+
suggestion: `Use one of the existing node ids: ${[...nodeIds].slice(0, 5).join(", ")}${nodeIds.size > 5 ? "..." : ""}`
|
|
2238
|
+
});
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
if (spec.encoding && typeof spec.encoding === "object") {
|
|
2242
|
+
const encoding = spec.encoding;
|
|
2243
|
+
const firstNode = nodes[0];
|
|
2244
|
+
const firstEdge = edges.length > 0 ? edges[0] : null;
|
|
2245
|
+
const nodeFields = firstNode ? new Set(Object.keys(firstNode)) : /* @__PURE__ */ new Set();
|
|
2246
|
+
const edgeFields = firstEdge ? new Set(Object.keys(firstEdge)) : /* @__PURE__ */ new Set();
|
|
2247
|
+
const nodeChannels = ["nodeColor", "nodeSize", "nodeLabel"];
|
|
2248
|
+
for (const channel of nodeChannels) {
|
|
2249
|
+
const ch = encoding[channel];
|
|
2250
|
+
if (ch?.field && typeof ch.field === "string" && !nodeFields.has(ch.field)) {
|
|
2251
|
+
errors.push({
|
|
2252
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on nodes. Available fields: ${[...nodeFields].join(", ")}`,
|
|
2253
|
+
path: `encoding.${channel}.field`,
|
|
2254
|
+
code: "DATA_FIELD_MISSING",
|
|
2255
|
+
suggestion: `Use one of the node fields: ${[...nodeFields].join(", ")}`
|
|
2256
|
+
});
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
const edgeChannels = ["edgeColor", "edgeWidth"];
|
|
2260
|
+
for (const channel of edgeChannels) {
|
|
2261
|
+
const ch = encoding[channel];
|
|
2262
|
+
if (ch?.field && typeof ch.field === "string" && firstEdge && !edgeFields.has(ch.field)) {
|
|
2263
|
+
errors.push({
|
|
2264
|
+
message: `Spec error: encoding.${channel}.field "${ch.field}" does not exist on edges. Available fields: ${[...edgeFields].join(", ")}`,
|
|
2265
|
+
path: `encoding.${channel}.field`,
|
|
2266
|
+
code: "DATA_FIELD_MISSING",
|
|
2267
|
+
suggestion: `Use one of the edge fields: ${[...edgeFields].join(", ")}`
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
if (spec.layout && typeof spec.layout === "object") {
|
|
2273
|
+
const layout = spec.layout;
|
|
2274
|
+
if (layout.type && layout.type !== "force") {
|
|
2275
|
+
errors.push({
|
|
2276
|
+
message: `Spec error: layout.type "${layout.type}" is not supported. Only "force" is currently supported`,
|
|
2277
|
+
path: "layout.type",
|
|
2278
|
+
code: "INVALID_VALUE",
|
|
2279
|
+
suggestion: 'Use layout.type: "force" or omit the layout field to use the default force layout'
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2284
|
+
function validateSpec(spec) {
|
|
2285
|
+
const errors = [];
|
|
2286
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) {
|
|
2287
|
+
return {
|
|
2288
|
+
valid: false,
|
|
2289
|
+
errors: [
|
|
2290
|
+
{
|
|
2291
|
+
message: "Spec error: spec must be a non-null object",
|
|
2292
|
+
code: "INVALID_TYPE",
|
|
2293
|
+
suggestion: 'Pass a spec object with at least a "type" field, e.g. { type: "line", data: [...], encoding: {...} }'
|
|
2294
|
+
}
|
|
2295
|
+
],
|
|
2296
|
+
normalized: null
|
|
2297
|
+
};
|
|
2298
|
+
}
|
|
2299
|
+
const obj = spec;
|
|
2300
|
+
if (!obj.type || typeof obj.type !== "string") {
|
|
2301
|
+
return {
|
|
2302
|
+
valid: false,
|
|
2303
|
+
errors: [
|
|
2304
|
+
{
|
|
2305
|
+
message: 'Spec error: spec must have a "type" field',
|
|
2306
|
+
path: "type",
|
|
2307
|
+
code: "MISSING_FIELD",
|
|
2308
|
+
suggestion: `Add a type field. Valid types: ${[...CHART_TYPES].join(", ")}, table, graph`
|
|
2309
|
+
}
|
|
2310
|
+
],
|
|
2311
|
+
normalized: null
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2314
|
+
const isChart = CHART_TYPES.has(obj.type);
|
|
2315
|
+
const isTable = obj.type === "table";
|
|
2316
|
+
const isGraph = obj.type === "graph";
|
|
2317
|
+
if (!isChart && !isTable && !isGraph) {
|
|
2318
|
+
return {
|
|
2319
|
+
valid: false,
|
|
2320
|
+
errors: [
|
|
2321
|
+
{
|
|
2322
|
+
message: `Spec error: "${obj.type}" is not a valid type. Valid types: ${[...CHART_TYPES].join(", ")}, table, graph`,
|
|
2323
|
+
path: "type",
|
|
2324
|
+
code: "INVALID_VALUE",
|
|
2325
|
+
suggestion: `Change type to one of: ${[...CHART_TYPES].join(", ")}, table, graph`
|
|
2326
|
+
}
|
|
2327
|
+
],
|
|
2328
|
+
normalized: null
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
if (isChart) {
|
|
2332
|
+
validateChartSpec(obj, errors);
|
|
2333
|
+
} else if (isTable) {
|
|
2334
|
+
validateTableSpec(obj, errors);
|
|
2335
|
+
} else if (isGraph) {
|
|
2336
|
+
validateGraphSpec(obj, errors);
|
|
2337
|
+
}
|
|
2338
|
+
if (errors.length > 0) {
|
|
2339
|
+
return { valid: false, errors, normalized: null };
|
|
2340
|
+
}
|
|
2341
|
+
return {
|
|
2342
|
+
valid: true,
|
|
2343
|
+
errors: [],
|
|
2344
|
+
normalized: spec
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// src/compiler/index.ts
|
|
2349
|
+
function compile(spec) {
|
|
2350
|
+
const validation = validateSpec(spec);
|
|
2351
|
+
if (!validation.valid || !validation.normalized) {
|
|
2352
|
+
const errorMessages = validation.errors.map((e) => e.message).join("\n");
|
|
2353
|
+
throw new Error(`Invalid spec:
|
|
2354
|
+
${errorMessages}`);
|
|
2355
|
+
}
|
|
2356
|
+
const warnings = [];
|
|
2357
|
+
const normalized = normalizeSpec(validation.normalized, warnings);
|
|
2358
|
+
return { spec: normalized, warnings };
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
// src/graphs/compile-graph.ts
|
|
2362
|
+
import { adaptTheme, computeChrome, resolveTheme } from "@opendata-ai/openchart-core";
|
|
2363
|
+
|
|
2364
|
+
// src/graphs/encoding.ts
|
|
2365
|
+
import { max as max2, min as min2 } from "d3-array";
|
|
2366
|
+
import { scaleLinear, scaleOrdinal, scaleSqrt as scaleSqrt2 } from "d3-scale";
|
|
2367
|
+
var DEFAULT_NODE_RADIUS = 5;
|
|
2368
|
+
var MIN_NODE_RADIUS = 3;
|
|
2369
|
+
var MAX_NODE_RADIUS = 20;
|
|
2370
|
+
var DEFAULT_EDGE_WIDTH = 1;
|
|
2371
|
+
var MIN_EDGE_WIDTH = 0.5;
|
|
2372
|
+
var MAX_EDGE_WIDTH = 4;
|
|
2373
|
+
var DEFAULT_STROKE_WIDTH2 = 1;
|
|
2374
|
+
function darkenColor(hex, amount = 0.2) {
|
|
2375
|
+
const clean = hex.replace(/^#/, "");
|
|
2376
|
+
if (clean.length !== 6 && clean.length !== 3) return hex;
|
|
2377
|
+
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
2378
|
+
const r = Math.max(0, Math.round(parseInt(full.substring(0, 2), 16) * (1 - amount)));
|
|
2379
|
+
const g = Math.max(0, Math.round(parseInt(full.substring(2, 4), 16) * (1 - amount)));
|
|
2380
|
+
const b = Math.max(0, Math.round(parseInt(full.substring(4, 6), 16) * (1 - amount)));
|
|
2381
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
2382
|
+
}
|
|
2383
|
+
function hexWithOpacity(hex, opacity) {
|
|
2384
|
+
const clean = hex.replace(/^#/, "");
|
|
2385
|
+
if (clean.length !== 6 && clean.length !== 3) {
|
|
2386
|
+
return hex;
|
|
2387
|
+
}
|
|
2388
|
+
const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean;
|
|
2389
|
+
const r = parseInt(full.substring(0, 2), 16);
|
|
2390
|
+
const g = parseInt(full.substring(2, 4), 16);
|
|
2391
|
+
const b = parseInt(full.substring(4, 6), 16);
|
|
2392
|
+
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
|
|
2393
|
+
}
|
|
2394
|
+
function computeDegrees(nodes, edges) {
|
|
2395
|
+
const degrees = /* @__PURE__ */ new Map();
|
|
2396
|
+
for (const node of nodes) {
|
|
2397
|
+
degrees.set(node.id, 0);
|
|
2398
|
+
}
|
|
2399
|
+
for (const edge of edges) {
|
|
2400
|
+
degrees.set(edge.source, (degrees.get(edge.source) ?? 0) + 1);
|
|
2401
|
+
degrees.set(edge.target, (degrees.get(edge.target) ?? 0) + 1);
|
|
2402
|
+
}
|
|
2403
|
+
return degrees;
|
|
2404
|
+
}
|
|
2405
|
+
function resolveNodeVisuals(nodes, encoding, edges, theme) {
|
|
2406
|
+
const degrees = computeDegrees(nodes, edges);
|
|
2407
|
+
const maxDegree = Math.max(1, ...degrees.values());
|
|
2408
|
+
let sizeScale;
|
|
2409
|
+
if (encoding.nodeSize?.field) {
|
|
2410
|
+
const field = encoding.nodeSize.field;
|
|
2411
|
+
const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
|
|
2412
|
+
const sizeMin = min2(values) ?? 0;
|
|
2413
|
+
const sizeMax = max2(values) ?? 1;
|
|
2414
|
+
sizeScale = scaleSqrt2().domain([sizeMin, sizeMax]).range([MIN_NODE_RADIUS, MAX_NODE_RADIUS]);
|
|
2415
|
+
}
|
|
2416
|
+
let colorFn;
|
|
2417
|
+
if (encoding.nodeColor?.field) {
|
|
2418
|
+
const field = encoding.nodeColor.field;
|
|
2419
|
+
const fieldType = encoding.nodeColor.type ?? "nominal";
|
|
2420
|
+
if (fieldType === "quantitative") {
|
|
2421
|
+
const values = nodes.map((n) => Number(n[field])).filter((v) => Number.isFinite(v));
|
|
2422
|
+
const colorMin = min2(values) ?? 0;
|
|
2423
|
+
const colorMax = max2(values) ?? 1;
|
|
2424
|
+
const seqPalettes = Object.values(theme.colors.sequential);
|
|
2425
|
+
const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
|
|
2426
|
+
const colorScale = scaleLinear().domain([colorMin, colorMax]).range([palette[0], palette[palette.length - 1]]);
|
|
2427
|
+
colorFn = (node) => {
|
|
2428
|
+
const val = Number(node[field]);
|
|
2429
|
+
return Number.isFinite(val) ? colorScale(val) : theme.colors.categorical[0];
|
|
2430
|
+
};
|
|
2431
|
+
} else {
|
|
2432
|
+
const uniqueValues = [...new Set(nodes.map((n) => String(n[field] ?? "")))];
|
|
2433
|
+
const ordinalScale = scaleOrdinal().domain(uniqueValues).range(theme.colors.categorical);
|
|
2434
|
+
colorFn = (node) => ordinalScale(String(node[field] ?? ""));
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
const defaultColor = theme.colors.categorical[0];
|
|
2438
|
+
return nodes.map((node) => {
|
|
2439
|
+
let radius = DEFAULT_NODE_RADIUS;
|
|
2440
|
+
if (sizeScale && encoding.nodeSize?.field) {
|
|
2441
|
+
const val = Number(node[encoding.nodeSize.field]);
|
|
2442
|
+
if (Number.isFinite(val)) {
|
|
2443
|
+
radius = sizeScale(val);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
const fill = colorFn ? colorFn(node) : defaultColor;
|
|
2447
|
+
const stroke = darkenColor(fill);
|
|
2448
|
+
let label;
|
|
2449
|
+
if (encoding.nodeLabel?.field) {
|
|
2450
|
+
const labelVal = node[encoding.nodeLabel.field];
|
|
2451
|
+
label = labelVal != null ? String(labelVal) : void 0;
|
|
2452
|
+
} else {
|
|
2453
|
+
label = node.id;
|
|
2454
|
+
}
|
|
2455
|
+
const degree = degrees.get(node.id) ?? 0;
|
|
2456
|
+
const labelPriority = maxDegree > 0 ? degree / maxDegree : 0;
|
|
2457
|
+
const { id: _id, ...rest } = node;
|
|
2458
|
+
const data = { id: node.id, ...rest };
|
|
2459
|
+
return {
|
|
2460
|
+
id: node.id,
|
|
2461
|
+
radius,
|
|
2462
|
+
fill,
|
|
2463
|
+
stroke,
|
|
2464
|
+
strokeWidth: DEFAULT_STROKE_WIDTH2,
|
|
2465
|
+
label,
|
|
2466
|
+
labelPriority,
|
|
2467
|
+
community: void 0,
|
|
2468
|
+
data
|
|
2469
|
+
};
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
function resolveEdgeVisuals(edges, encoding, theme) {
|
|
2473
|
+
let widthScale;
|
|
2474
|
+
if (encoding.edgeWidth?.field) {
|
|
2475
|
+
const field = encoding.edgeWidth.field;
|
|
2476
|
+
const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
|
|
2477
|
+
const widthMin = min2(values) ?? 0;
|
|
2478
|
+
const widthMax = max2(values) ?? 1;
|
|
2479
|
+
widthScale = scaleLinear().domain([widthMin, widthMax]).range([MIN_EDGE_WIDTH, MAX_EDGE_WIDTH]);
|
|
2480
|
+
}
|
|
2481
|
+
let edgeColorFn;
|
|
2482
|
+
if (encoding.edgeColor?.field) {
|
|
2483
|
+
const field = encoding.edgeColor.field;
|
|
2484
|
+
const fieldType = encoding.edgeColor.type ?? "nominal";
|
|
2485
|
+
if (fieldType === "quantitative") {
|
|
2486
|
+
const values = edges.map((e) => Number(e[field])).filter((v) => Number.isFinite(v));
|
|
2487
|
+
const colorMin = min2(values) ?? 0;
|
|
2488
|
+
const colorMax = max2(values) ?? 1;
|
|
2489
|
+
const seqPalettes = Object.values(theme.colors.sequential);
|
|
2490
|
+
const palette = seqPalettes.length > 0 ? seqPalettes[0] : ["#ccc", "#333"];
|
|
2491
|
+
const colorScale = scaleLinear().domain([colorMin, colorMax]).range([palette[0], palette[palette.length - 1]]);
|
|
2492
|
+
edgeColorFn = (edge) => {
|
|
2493
|
+
const val = Number(edge[field]);
|
|
2494
|
+
return Number.isFinite(val) ? colorScale(val) : hexWithOpacity(theme.colors.axis, 0.4);
|
|
2495
|
+
};
|
|
2496
|
+
} else {
|
|
2497
|
+
const uniqueValues = [...new Set(edges.map((e) => String(e[field] ?? "")))];
|
|
2498
|
+
const ordinalScale = scaleOrdinal().domain(uniqueValues).range(theme.colors.categorical);
|
|
2499
|
+
edgeColorFn = (edge) => ordinalScale(String(edge[field] ?? ""));
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
const defaultEdgeColor = hexWithOpacity(theme.colors.axis, 0.4);
|
|
2503
|
+
return edges.map((edge) => {
|
|
2504
|
+
const { source, target, ...rest } = edge;
|
|
2505
|
+
let strokeWidth = DEFAULT_EDGE_WIDTH;
|
|
2506
|
+
if (widthScale && encoding.edgeWidth?.field) {
|
|
2507
|
+
const val = Number(edge[encoding.edgeWidth.field]);
|
|
2508
|
+
if (Number.isFinite(val)) {
|
|
2509
|
+
strokeWidth = widthScale(val);
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
const stroke = edgeColorFn ? edgeColorFn(edge) : defaultEdgeColor;
|
|
2513
|
+
return {
|
|
2514
|
+
source,
|
|
2515
|
+
target,
|
|
2516
|
+
stroke,
|
|
2517
|
+
strokeWidth,
|
|
2518
|
+
style: "solid",
|
|
2519
|
+
data: { source, target, ...rest }
|
|
2520
|
+
};
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
// src/graphs/community.ts
|
|
2525
|
+
function assignCommunities(nodes, clusteringField) {
|
|
2526
|
+
if (!clusteringField) return;
|
|
2527
|
+
for (const node of nodes) {
|
|
2528
|
+
const value = node.data[clusteringField];
|
|
2529
|
+
node.community = value != null ? String(value) : void 0;
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
function buildCommunityColorMap(nodes, theme) {
|
|
2533
|
+
const colorMap = /* @__PURE__ */ new Map();
|
|
2534
|
+
const palette = theme.colors.categorical;
|
|
2535
|
+
let colorIndex = 0;
|
|
2536
|
+
for (const node of nodes) {
|
|
2537
|
+
if (node.community != null && !colorMap.has(node.community)) {
|
|
2538
|
+
colorMap.set(node.community, palette[colorIndex % palette.length]);
|
|
2539
|
+
colorIndex++;
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
return colorMap;
|
|
2543
|
+
}
|
|
2544
|
+
function applyCommunityColors(nodes, colorMap) {
|
|
2545
|
+
for (const node of nodes) {
|
|
2546
|
+
if (node.community != null) {
|
|
2547
|
+
const communityColor = colorMap.get(node.community);
|
|
2548
|
+
if (communityColor) {
|
|
2549
|
+
node.fill = communityColor;
|
|
2550
|
+
node.stroke = darkenColor(communityColor);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
// src/graphs/compile-graph.ts
|
|
2557
|
+
var SWATCH_SIZE = 12;
|
|
2558
|
+
var SWATCH_GAP = 6;
|
|
2559
|
+
var ENTRY_GAP = 16;
|
|
2560
|
+
function buildGraphLegend(nodes, communityColorMap, hasCommunities, theme) {
|
|
2561
|
+
const labelStyle = {
|
|
2562
|
+
fontFamily: theme.fonts.family,
|
|
2563
|
+
fontSize: theme.fonts.sizes.small,
|
|
2564
|
+
fontWeight: theme.fonts.weights.normal,
|
|
2565
|
+
fill: theme.colors.text,
|
|
2566
|
+
lineHeight: 1.3
|
|
2567
|
+
};
|
|
2568
|
+
let entries;
|
|
2569
|
+
if (hasCommunities && communityColorMap.size > 0) {
|
|
2570
|
+
entries = [...communityColorMap.entries()].map(([label, color]) => ({
|
|
2571
|
+
label,
|
|
2572
|
+
color,
|
|
2573
|
+
shape: "circle",
|
|
2574
|
+
active: true
|
|
2575
|
+
}));
|
|
2576
|
+
} else {
|
|
2577
|
+
const colorLabels = /* @__PURE__ */ new Map();
|
|
2578
|
+
for (const node of nodes) {
|
|
2579
|
+
if (!colorLabels.has(node.fill)) {
|
|
2580
|
+
colorLabels.set(node.fill, node.label ?? node.id);
|
|
2581
|
+
}
|
|
2582
|
+
}
|
|
2583
|
+
if (colorLabels.size <= 1) {
|
|
2584
|
+
entries = [];
|
|
2585
|
+
} else {
|
|
2586
|
+
entries = [...colorLabels.entries()].map(([color, label]) => ({
|
|
2587
|
+
label,
|
|
2588
|
+
color,
|
|
2589
|
+
shape: "circle",
|
|
2590
|
+
active: true
|
|
2591
|
+
}));
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
return {
|
|
2595
|
+
position: "top",
|
|
2596
|
+
entries,
|
|
2597
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
2598
|
+
labelStyle,
|
|
2599
|
+
swatchSize: SWATCH_SIZE,
|
|
2600
|
+
swatchGap: SWATCH_GAP,
|
|
2601
|
+
entryGap: ENTRY_GAP
|
|
2602
|
+
};
|
|
2603
|
+
}
|
|
2604
|
+
function buildGraphTooltips(nodes) {
|
|
2605
|
+
const descriptors = /* @__PURE__ */ new Map();
|
|
2606
|
+
for (const node of nodes) {
|
|
2607
|
+
const fields = [];
|
|
2608
|
+
if (node.community != null) {
|
|
2609
|
+
fields.push({
|
|
2610
|
+
label: "Community",
|
|
2611
|
+
value: node.community,
|
|
2612
|
+
color: node.fill
|
|
2613
|
+
});
|
|
2614
|
+
}
|
|
2615
|
+
for (const [key, value] of Object.entries(node.data)) {
|
|
2616
|
+
if (key === "id") continue;
|
|
2617
|
+
if (value == null) continue;
|
|
2618
|
+
fields.push({
|
|
2619
|
+
label: key,
|
|
2620
|
+
value: typeof value === "number" ? value.toLocaleString() : String(value)
|
|
2621
|
+
});
|
|
2622
|
+
}
|
|
2623
|
+
descriptors.set(node.id, {
|
|
2624
|
+
title: node.label ?? node.id,
|
|
2625
|
+
fields
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
return descriptors;
|
|
2629
|
+
}
|
|
2630
|
+
function compileGraph(spec, options) {
|
|
2631
|
+
const { spec: normalized } = compile(spec);
|
|
2632
|
+
if (normalized.type !== "graph") {
|
|
2633
|
+
throw new Error(
|
|
2634
|
+
`compileGraph received a ${normalized.type} spec. Use compileChart or compileTable instead.`
|
|
2635
|
+
);
|
|
2636
|
+
}
|
|
2637
|
+
const graphSpec = normalized;
|
|
2638
|
+
const mergedThemeConfig = options.theme ? { ...graphSpec.theme, ...options.theme } : graphSpec.theme;
|
|
2639
|
+
let theme = resolveTheme(mergedThemeConfig);
|
|
2640
|
+
if (options.darkMode) {
|
|
2641
|
+
theme = adaptTheme(theme);
|
|
2642
|
+
}
|
|
2643
|
+
const compiledNodes = resolveNodeVisuals(
|
|
2644
|
+
graphSpec.nodes,
|
|
2645
|
+
graphSpec.encoding,
|
|
2646
|
+
graphSpec.edges,
|
|
2647
|
+
theme
|
|
2648
|
+
);
|
|
2649
|
+
const clusteringField = graphSpec.layout.clustering?.field;
|
|
2650
|
+
const hasCommunities = !!clusteringField;
|
|
2651
|
+
assignCommunities(compiledNodes, clusteringField);
|
|
2652
|
+
let communityColorMap = /* @__PURE__ */ new Map();
|
|
2653
|
+
if (hasCommunities) {
|
|
2654
|
+
communityColorMap = buildCommunityColorMap(compiledNodes, theme);
|
|
2655
|
+
applyCommunityColors(compiledNodes, communityColorMap);
|
|
2656
|
+
}
|
|
2657
|
+
const compiledEdges = resolveEdgeVisuals(graphSpec.edges, graphSpec.encoding, theme);
|
|
2658
|
+
const legend = buildGraphLegend(compiledNodes, communityColorMap, hasCommunities, theme);
|
|
2659
|
+
const tooltipDescriptors = buildGraphTooltips(compiledNodes);
|
|
2660
|
+
const communityCount = communityColorMap.size;
|
|
2661
|
+
const altParts = [
|
|
2662
|
+
`Network graph with ${compiledNodes.length} nodes and ${compiledEdges.length} edges`
|
|
2663
|
+
];
|
|
2664
|
+
if (communityCount > 0) {
|
|
2665
|
+
altParts.push(`organized into ${communityCount} communities`);
|
|
2666
|
+
}
|
|
2667
|
+
const a11y = {
|
|
2668
|
+
altText: altParts.join(", "),
|
|
2669
|
+
dataTableFallback: compiledNodes.map((n) => [n.id, n.community ?? "", n.label ?? ""]),
|
|
2670
|
+
role: "img",
|
|
2671
|
+
keyboardNavigable: compiledNodes.length > 0
|
|
2672
|
+
};
|
|
2673
|
+
const maxRadius = compiledNodes.length > 0 ? Math.max(...compiledNodes.map((n) => n.radius)) : DEFAULT_COLLISION_PADDING;
|
|
2674
|
+
const simulationConfig = {
|
|
2675
|
+
chargeStrength: graphSpec.layout.chargeStrength ?? -300,
|
|
2676
|
+
linkDistance: graphSpec.layout.linkDistance ?? 30,
|
|
2677
|
+
clustering: clusteringField ? { field: clusteringField, strength: 0.5 } : null,
|
|
2678
|
+
alphaDecay: 0.0228,
|
|
2679
|
+
velocityDecay: 0.4,
|
|
2680
|
+
collisionRadius: maxRadius + 2
|
|
2681
|
+
};
|
|
2682
|
+
const chrome = computeChrome(
|
|
2683
|
+
{
|
|
2684
|
+
title: graphSpec.chrome.title,
|
|
2685
|
+
subtitle: graphSpec.chrome.subtitle,
|
|
2686
|
+
source: graphSpec.chrome.source,
|
|
2687
|
+
byline: graphSpec.chrome.byline,
|
|
2688
|
+
footer: graphSpec.chrome.footer
|
|
2689
|
+
},
|
|
2690
|
+
theme,
|
|
2691
|
+
options.width,
|
|
2692
|
+
options.measureText
|
|
2693
|
+
);
|
|
2694
|
+
return {
|
|
2695
|
+
nodes: compiledNodes,
|
|
2696
|
+
edges: compiledEdges,
|
|
2697
|
+
legend,
|
|
2698
|
+
chrome,
|
|
2699
|
+
tooltipDescriptors,
|
|
2700
|
+
a11y,
|
|
2701
|
+
theme,
|
|
2702
|
+
dimensions: {
|
|
2703
|
+
width: options.width,
|
|
2704
|
+
height: options.height
|
|
2705
|
+
},
|
|
2706
|
+
simulationConfig
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
var DEFAULT_COLLISION_PADDING = 5;
|
|
2710
|
+
|
|
2711
|
+
// src/layout/axes.ts
|
|
2712
|
+
import { abbreviateNumber as abbreviateNumber3, formatDate, formatNumber as formatNumber3 } from "@opendata-ai/openchart-core";
|
|
2713
|
+
var TICK_COUNTS = {
|
|
2714
|
+
full: 8,
|
|
2715
|
+
reduced: 5,
|
|
2716
|
+
minimal: 3
|
|
2717
|
+
};
|
|
2718
|
+
function continuousTicks(resolvedScale, density) {
|
|
2719
|
+
const scale = resolvedScale.scale;
|
|
2720
|
+
const count = resolvedScale.channel.axis?.tickCount ?? TICK_COUNTS[density];
|
|
2721
|
+
const ticks = scale.ticks(count);
|
|
2722
|
+
return ticks.map((value) => ({
|
|
2723
|
+
value,
|
|
2724
|
+
position: scale(value),
|
|
2725
|
+
label: formatTickLabel(value, resolvedScale)
|
|
2726
|
+
}));
|
|
2727
|
+
}
|
|
2728
|
+
function categoricalTicks(resolvedScale, density) {
|
|
2729
|
+
const scale = resolvedScale.scale;
|
|
2730
|
+
const domain = scale.domain();
|
|
2731
|
+
const maxTicks = TICK_COUNTS[density];
|
|
2732
|
+
let selectedValues = domain;
|
|
2733
|
+
if (resolvedScale.type !== "band" && domain.length > maxTicks) {
|
|
2734
|
+
const step = Math.ceil(domain.length / maxTicks);
|
|
2735
|
+
selectedValues = domain.filter((_, i) => i % step === 0);
|
|
2736
|
+
}
|
|
2737
|
+
return selectedValues.map((value) => {
|
|
2738
|
+
const bandScale = resolvedScale.type === "band" ? scale : null;
|
|
2739
|
+
const pos = bandScale ? (bandScale(value) ?? 0) + bandScale.bandwidth() / 2 : scale(value) ?? 0;
|
|
2740
|
+
return {
|
|
2741
|
+
value,
|
|
2742
|
+
position: pos,
|
|
2743
|
+
label: value
|
|
2744
|
+
};
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
function formatTickLabel(value, resolvedScale) {
|
|
2748
|
+
const formatStr = resolvedScale.channel.axis?.format;
|
|
2749
|
+
if (resolvedScale.type === "time") {
|
|
2750
|
+
if (formatStr) return String(value);
|
|
2751
|
+
return formatDate(value);
|
|
2752
|
+
}
|
|
2753
|
+
if (resolvedScale.type === "linear" || resolvedScale.type === "log") {
|
|
2754
|
+
const num = value;
|
|
2755
|
+
if (formatStr) return formatNumber3(num);
|
|
2756
|
+
if (Math.abs(num) >= 1e3) return abbreviateNumber3(num);
|
|
2757
|
+
return formatNumber3(num);
|
|
2758
|
+
}
|
|
2759
|
+
return String(value);
|
|
2760
|
+
}
|
|
2761
|
+
function computeAxes(scales, chartArea, strategy, theme) {
|
|
2762
|
+
const result = {};
|
|
2763
|
+
const density = strategy.axisLabelDensity;
|
|
2764
|
+
const tickLabelStyle = {
|
|
2765
|
+
fontFamily: theme.fonts.family,
|
|
2766
|
+
fontSize: theme.fonts.sizes.axisTick,
|
|
2767
|
+
fontWeight: theme.fonts.weights.normal,
|
|
2768
|
+
fill: theme.colors.axis,
|
|
2769
|
+
lineHeight: 1.2,
|
|
2770
|
+
fontVariant: "tabular-nums"
|
|
2771
|
+
};
|
|
2772
|
+
const axisLabelStyle = {
|
|
2773
|
+
fontFamily: theme.fonts.family,
|
|
2774
|
+
fontSize: theme.fonts.sizes.body,
|
|
2775
|
+
fontWeight: theme.fonts.weights.medium,
|
|
2776
|
+
fill: theme.colors.text,
|
|
2777
|
+
lineHeight: 1.3
|
|
2778
|
+
};
|
|
2779
|
+
if (scales.x) {
|
|
2780
|
+
const ticks = scales.x.type === "band" || scales.x.type === "point" || scales.x.type === "ordinal" ? categoricalTicks(scales.x, density) : continuousTicks(scales.x, density);
|
|
2781
|
+
const gridlines = ticks.map((t) => ({
|
|
2782
|
+
position: t.position,
|
|
2783
|
+
major: true
|
|
2784
|
+
}));
|
|
2785
|
+
result.x = {
|
|
2786
|
+
ticks,
|
|
2787
|
+
gridlines: scales.x.channel.axis?.grid ? gridlines : [],
|
|
2788
|
+
label: scales.x.channel.axis?.label,
|
|
2789
|
+
labelStyle: axisLabelStyle,
|
|
2790
|
+
tickLabelStyle,
|
|
2791
|
+
start: { x: chartArea.x, y: chartArea.y + chartArea.height },
|
|
2792
|
+
end: { x: chartArea.x + chartArea.width, y: chartArea.y + chartArea.height }
|
|
2793
|
+
};
|
|
2794
|
+
}
|
|
2795
|
+
if (scales.y) {
|
|
2796
|
+
const ticks = scales.y.type === "band" || scales.y.type === "point" || scales.y.type === "ordinal" ? categoricalTicks(scales.y, density) : continuousTicks(scales.y, density);
|
|
2797
|
+
const gridlines = ticks.map((t) => ({
|
|
2798
|
+
position: t.position,
|
|
2799
|
+
major: true
|
|
2800
|
+
}));
|
|
2801
|
+
result.y = {
|
|
2802
|
+
ticks,
|
|
2803
|
+
// Y-axis gridlines are shown by default (standard editorial practice)
|
|
2804
|
+
gridlines,
|
|
2805
|
+
label: scales.y.channel.axis?.label,
|
|
2806
|
+
labelStyle: axisLabelStyle,
|
|
2807
|
+
tickLabelStyle,
|
|
2808
|
+
start: { x: chartArea.x, y: chartArea.y },
|
|
2809
|
+
end: { x: chartArea.x, y: chartArea.y + chartArea.height }
|
|
2810
|
+
};
|
|
2811
|
+
}
|
|
2812
|
+
return result;
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
// src/layout/dimensions.ts
|
|
2816
|
+
import { computeChrome as computeChrome2, estimateTextWidth as estimateTextWidth7 } from "@opendata-ai/openchart-core";
|
|
2817
|
+
function chromeToInput(chrome) {
|
|
2818
|
+
return {
|
|
2819
|
+
title: chrome.title,
|
|
2820
|
+
subtitle: chrome.subtitle,
|
|
2821
|
+
source: chrome.source,
|
|
2822
|
+
byline: chrome.byline,
|
|
2823
|
+
footer: chrome.footer
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
function computeDimensions(spec, options, legendLayout, theme) {
|
|
2827
|
+
const { width, height } = options;
|
|
2828
|
+
const padding = theme.spacing.padding;
|
|
2829
|
+
const axisMargin = theme.spacing.axisMargin;
|
|
2830
|
+
const chrome = computeChrome2(chromeToInput(spec.chrome), theme, width, options.measureText);
|
|
2831
|
+
const total = { x: 0, y: 0, width, height };
|
|
2832
|
+
const isRadial = spec.type === "pie" || spec.type === "donut";
|
|
2833
|
+
const encoding = spec.encoding;
|
|
2834
|
+
const hasXAxisLabel = !!encoding.x?.axis?.label;
|
|
2835
|
+
const xAxisHeight = isRadial ? 0 : hasXAxisLabel ? 48 : 26;
|
|
2836
|
+
const margins = {
|
|
2837
|
+
top: padding + chrome.topHeight + axisMargin,
|
|
2838
|
+
right: padding + (isRadial ? padding : axisMargin),
|
|
2839
|
+
bottom: padding + chrome.bottomHeight + xAxisHeight,
|
|
2840
|
+
left: padding + (isRadial ? padding : axisMargin)
|
|
2841
|
+
};
|
|
2842
|
+
if (spec.type === "line" || spec.type === "area") {
|
|
2843
|
+
const colorField = encoding.color?.field;
|
|
2844
|
+
if (colorField) {
|
|
2845
|
+
let maxLabelWidth = 0;
|
|
2846
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2847
|
+
for (const row of spec.data) {
|
|
2848
|
+
const label = String(row[colorField] ?? "");
|
|
2849
|
+
if (!seen.has(label)) {
|
|
2850
|
+
seen.add(label);
|
|
2851
|
+
const w = estimateTextWidth7(label, 11, 600);
|
|
2852
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
if (maxLabelWidth > 0) {
|
|
2856
|
+
margins.right = Math.max(margins.right, padding + maxLabelWidth + 16);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
if (encoding.y && !isRadial) {
|
|
2861
|
+
if (spec.type === "bar" || spec.type === "dot" || encoding.y.type === "nominal" || encoding.y.type === "ordinal") {
|
|
2862
|
+
const yField = encoding.y.field;
|
|
2863
|
+
let maxLabelWidth = 0;
|
|
2864
|
+
for (const row of spec.data) {
|
|
2865
|
+
const label = String(row[yField] ?? "");
|
|
2866
|
+
const w = estimateTextWidth7(label, theme.fonts.sizes.axisTick, theme.fonts.weights.normal);
|
|
2867
|
+
if (w > maxLabelWidth) maxLabelWidth = w;
|
|
2868
|
+
}
|
|
2869
|
+
if (maxLabelWidth > 0) {
|
|
2870
|
+
margins.left = Math.max(margins.left, padding + maxLabelWidth + 12);
|
|
2871
|
+
}
|
|
2872
|
+
} else if (encoding.y.type === "quantitative" || encoding.y.type === "temporal") {
|
|
2873
|
+
const yField = encoding.y.field;
|
|
2874
|
+
let maxAbsVal = 0;
|
|
2875
|
+
for (const row of spec.data) {
|
|
2876
|
+
const v = Number(row[yField]);
|
|
2877
|
+
if (Number.isFinite(v) && Math.abs(v) > maxAbsVal) maxAbsVal = Math.abs(v);
|
|
2878
|
+
}
|
|
2879
|
+
let sampleLabel;
|
|
2880
|
+
if (maxAbsVal >= 1e9) sampleLabel = "1.5B";
|
|
2881
|
+
else if (maxAbsVal >= 1e6) sampleLabel = "1.5M";
|
|
2882
|
+
else if (maxAbsVal >= 1e3) sampleLabel = "1.5K";
|
|
2883
|
+
else if (maxAbsVal >= 100) sampleLabel = "100";
|
|
2884
|
+
else if (maxAbsVal >= 10) sampleLabel = "10";
|
|
2885
|
+
else sampleLabel = "0.0";
|
|
2886
|
+
const negPrefix = spec.data.some((r) => Number(r[yField]) < 0) ? "-" : "";
|
|
2887
|
+
const labelEst = negPrefix + sampleLabel;
|
|
2888
|
+
const labelWidth = estimateTextWidth7(
|
|
2889
|
+
labelEst,
|
|
2890
|
+
theme.fonts.sizes.axisTick,
|
|
2891
|
+
theme.fonts.weights.normal
|
|
2892
|
+
);
|
|
2893
|
+
margins.left = Math.max(margins.left, padding + labelWidth + 10);
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
if (encoding.y?.axis && encoding.y.axis.label && !isRadial) {
|
|
2897
|
+
const rotatedLabelMargin = 45 + Math.ceil(theme.fonts.sizes.body / 2) + 4;
|
|
2898
|
+
margins.left = Math.max(margins.left, padding + rotatedLabelMargin);
|
|
2899
|
+
}
|
|
2900
|
+
if (legendLayout.entries.length > 0) {
|
|
2901
|
+
if (legendLayout.position === "right" || legendLayout.position === "bottom-right") {
|
|
2902
|
+
margins.right += legendLayout.bounds.width + 8;
|
|
2903
|
+
} else if (legendLayout.position === "top") {
|
|
2904
|
+
margins.top += legendLayout.bounds.height + 4;
|
|
2905
|
+
} else if (legendLayout.position === "bottom") {
|
|
2906
|
+
margins.bottom += legendLayout.bounds.height + 4;
|
|
2907
|
+
}
|
|
2908
|
+
}
|
|
2909
|
+
const chartArea = {
|
|
2910
|
+
x: margins.left,
|
|
2911
|
+
y: margins.top,
|
|
2912
|
+
width: Math.max(0, width - margins.left - margins.right),
|
|
2913
|
+
height: Math.max(0, height - margins.top - margins.bottom)
|
|
2914
|
+
};
|
|
2915
|
+
return { total, chrome, chartArea, margins, theme };
|
|
2916
|
+
}
|
|
2917
|
+
|
|
2918
|
+
// src/layout/gridlines.ts
|
|
2919
|
+
function computeGridlines(axes, chartArea, showVertical = false) {
|
|
2920
|
+
const horizontal = [];
|
|
2921
|
+
const vertical = [];
|
|
2922
|
+
if (axes.y) {
|
|
2923
|
+
for (const gridline of axes.y.gridlines) {
|
|
2924
|
+
horizontal.push({
|
|
2925
|
+
x1: chartArea.x,
|
|
2926
|
+
y1: gridline.position,
|
|
2927
|
+
x2: chartArea.x + chartArea.width,
|
|
2928
|
+
y2: gridline.position,
|
|
2929
|
+
major: gridline.major
|
|
2930
|
+
});
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
if (showVertical && axes.x) {
|
|
2934
|
+
for (const gridline of axes.x.gridlines) {
|
|
2935
|
+
vertical.push({
|
|
2936
|
+
x1: gridline.position,
|
|
2937
|
+
y1: chartArea.y,
|
|
2938
|
+
x2: gridline.position,
|
|
2939
|
+
y2: chartArea.y + chartArea.height,
|
|
2940
|
+
major: gridline.major
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return { horizontal, vertical };
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
// src/layout/scales.ts
|
|
2948
|
+
import { extent, max as max3, min as min3 } from "d3-array";
|
|
2949
|
+
import { scaleBand, scaleLinear as scaleLinear2, scaleLog, scaleOrdinal as scaleOrdinal2, scalePoint, scaleTime } from "d3-scale";
|
|
2950
|
+
function fieldValues(data, field) {
|
|
2951
|
+
return data.map((d) => d[field]).filter((v) => v != null);
|
|
2952
|
+
}
|
|
2953
|
+
function parseDates(values) {
|
|
2954
|
+
return values.map((v) => v instanceof Date ? v : new Date(String(v))).filter((d) => !Number.isNaN(d.getTime()));
|
|
2955
|
+
}
|
|
2956
|
+
function parseNumbers(values) {
|
|
2957
|
+
return values.map((v) => typeof v === "number" ? v : Number(v)).filter((n) => Number.isFinite(n));
|
|
2958
|
+
}
|
|
2959
|
+
function uniqueStrings(values) {
|
|
2960
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2961
|
+
const result = [];
|
|
2962
|
+
for (const v of values) {
|
|
2963
|
+
const s = String(v);
|
|
2964
|
+
if (!seen.has(s)) {
|
|
2965
|
+
seen.add(s);
|
|
2966
|
+
result.push(s);
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
return result;
|
|
2970
|
+
}
|
|
2971
|
+
function buildTimeScale(channel, data, rangeStart, rangeEnd) {
|
|
2972
|
+
const values = parseDates(fieldValues(data, channel.field));
|
|
2973
|
+
const domain = channel.scale?.domain ? [new Date(channel.scale.domain[0]), new Date(channel.scale.domain[1])] : extent(values);
|
|
2974
|
+
const scale = scaleTime().domain(domain).range([rangeStart, rangeEnd]);
|
|
2975
|
+
if (channel.scale?.nice !== false) {
|
|
2976
|
+
scale.nice();
|
|
2977
|
+
}
|
|
2978
|
+
return { scale, type: "time", channel };
|
|
2979
|
+
}
|
|
2980
|
+
function buildLinearScale(channel, data, rangeStart, rangeEnd) {
|
|
2981
|
+
const values = parseNumbers(fieldValues(data, channel.field));
|
|
2982
|
+
let domainMin;
|
|
2983
|
+
let domainMax;
|
|
2984
|
+
if (channel.scale?.domain) {
|
|
2985
|
+
const [d0, d1] = channel.scale.domain;
|
|
2986
|
+
domainMin = d0;
|
|
2987
|
+
domainMax = d1;
|
|
2988
|
+
} else {
|
|
2989
|
+
domainMin = min3(values) ?? 0;
|
|
2990
|
+
domainMax = max3(values) ?? 1;
|
|
2991
|
+
if (channel.scale?.zero !== false) {
|
|
2992
|
+
domainMin = Math.min(0, domainMin);
|
|
2993
|
+
domainMax = Math.max(0, domainMax);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
const scale = scaleLinear2().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]);
|
|
2997
|
+
if (channel.scale?.nice !== false) {
|
|
2998
|
+
scale.nice();
|
|
2999
|
+
}
|
|
3000
|
+
return { scale, type: "linear", channel };
|
|
3001
|
+
}
|
|
3002
|
+
function buildLogScale(channel, data, rangeStart, rangeEnd) {
|
|
3003
|
+
const values = parseNumbers(fieldValues(data, channel.field)).filter((v) => v > 0);
|
|
3004
|
+
const domainMin = min3(values) ?? 1;
|
|
3005
|
+
const domainMax = max3(values) ?? 10;
|
|
3006
|
+
const scale = scaleLog().domain([domainMin, domainMax]).range([rangeStart, rangeEnd]).nice();
|
|
3007
|
+
return { scale, type: "log", channel };
|
|
3008
|
+
}
|
|
3009
|
+
function buildBandScale(channel, data, rangeStart, rangeEnd) {
|
|
3010
|
+
const values = channel.scale?.domain ? channel.scale.domain : uniqueStrings(fieldValues(data, channel.field));
|
|
3011
|
+
const scale = scaleBand().domain(values).range([rangeStart, rangeEnd]).padding(0.35);
|
|
3012
|
+
return { scale, type: "band", channel };
|
|
3013
|
+
}
|
|
3014
|
+
function buildPointScale(channel, data, rangeStart, rangeEnd) {
|
|
3015
|
+
const values = channel.scale?.domain ? channel.scale.domain : uniqueStrings(fieldValues(data, channel.field));
|
|
3016
|
+
const scale = scalePoint().domain(values).range([rangeStart, rangeEnd]).padding(0.5);
|
|
3017
|
+
return { scale, type: "point", channel };
|
|
3018
|
+
}
|
|
3019
|
+
function buildOrdinalColorScale(channel, data, palette) {
|
|
3020
|
+
const values = uniqueStrings(fieldValues(data, channel.field));
|
|
3021
|
+
const scale = scaleOrdinal2().domain(values).range(palette);
|
|
3022
|
+
return { scale, type: "ordinal", channel };
|
|
3023
|
+
}
|
|
3024
|
+
function buildSequentialColorScale(channel, data, palette) {
|
|
3025
|
+
const values = parseNumbers(fieldValues(data, channel.field));
|
|
3026
|
+
const domainMin = min3(values) ?? 0;
|
|
3027
|
+
const domainMax = max3(values) ?? 1;
|
|
3028
|
+
const scale = scaleLinear2().domain([domainMin, domainMax]).range([palette[0], palette[palette.length - 1]]).clamp(true);
|
|
3029
|
+
return { scale, type: "sequential", channel };
|
|
3030
|
+
}
|
|
3031
|
+
function buildPositionalScale(channel, data, rangeStart, rangeEnd, chartType, axis) {
|
|
3032
|
+
if (channel.scale?.type) {
|
|
3033
|
+
switch (channel.scale.type) {
|
|
3034
|
+
case "time":
|
|
3035
|
+
return buildTimeScale(channel, data, rangeStart, rangeEnd);
|
|
3036
|
+
case "linear":
|
|
3037
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
3038
|
+
case "log":
|
|
3039
|
+
return buildLogScale(channel, data, rangeStart, rangeEnd);
|
|
3040
|
+
case "band":
|
|
3041
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
3042
|
+
case "point":
|
|
3043
|
+
return buildPointScale(channel, data, rangeStart, rangeEnd);
|
|
3044
|
+
case "ordinal":
|
|
3045
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
3046
|
+
}
|
|
3047
|
+
}
|
|
3048
|
+
switch (channel.type) {
|
|
3049
|
+
case "temporal":
|
|
3050
|
+
return buildTimeScale(channel, data, rangeStart, rangeEnd);
|
|
3051
|
+
case "quantitative":
|
|
3052
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
3053
|
+
case "nominal":
|
|
3054
|
+
case "ordinal":
|
|
3055
|
+
if (chartType === "bar" && axis === "y" || chartType === "column" && axis === "x" || chartType === "dot" && axis === "y") {
|
|
3056
|
+
return buildBandScale(channel, data, rangeStart, rangeEnd);
|
|
3057
|
+
}
|
|
3058
|
+
return buildPointScale(channel, data, rangeStart, rangeEnd);
|
|
3059
|
+
default:
|
|
3060
|
+
return buildLinearScale(channel, data, rangeStart, rangeEnd);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
function computeScales(spec, chartArea, data) {
|
|
3064
|
+
const result = {};
|
|
3065
|
+
const encoding = spec.encoding;
|
|
3066
|
+
if (spec.type === "scatter") {
|
|
3067
|
+
if (encoding.x?.type === "quantitative" && encoding.x.scale?.zero === void 0) {
|
|
3068
|
+
if (!encoding.x.scale) {
|
|
3069
|
+
encoding.x.scale = { zero: false };
|
|
3070
|
+
} else {
|
|
3071
|
+
encoding.x.scale.zero = false;
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
if (encoding.y?.type === "quantitative" && encoding.y.scale?.zero === void 0) {
|
|
3075
|
+
if (!encoding.y.scale) {
|
|
3076
|
+
encoding.y.scale = { zero: false };
|
|
3077
|
+
} else {
|
|
3078
|
+
encoding.y.scale.zero = false;
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
if (encoding.x) {
|
|
3083
|
+
let xData = data;
|
|
3084
|
+
if (spec.type === "bar" && encoding.color && encoding.x.type === "quantitative") {
|
|
3085
|
+
const yField = encoding.y?.field;
|
|
3086
|
+
const xField = encoding.x.field;
|
|
3087
|
+
if (yField) {
|
|
3088
|
+
const sums = /* @__PURE__ */ new Map();
|
|
3089
|
+
for (const row of data) {
|
|
3090
|
+
const cat = String(row[yField] ?? "");
|
|
3091
|
+
const val = Number(row[xField] ?? 0);
|
|
3092
|
+
if (Number.isFinite(val) && val > 0) {
|
|
3093
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
3097
|
+
xData = [...data, { [xField]: maxSum }];
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
result.x = buildPositionalScale(
|
|
3101
|
+
encoding.x,
|
|
3102
|
+
xData,
|
|
3103
|
+
chartArea.x,
|
|
3104
|
+
chartArea.x + chartArea.width,
|
|
3105
|
+
spec.type,
|
|
3106
|
+
"x"
|
|
3107
|
+
);
|
|
3108
|
+
}
|
|
3109
|
+
if (encoding.y) {
|
|
3110
|
+
let yData = data;
|
|
3111
|
+
if (spec.type === "column" && encoding.color && encoding.y.type === "quantitative") {
|
|
3112
|
+
const xField = encoding.x?.field;
|
|
3113
|
+
const yField = encoding.y.field;
|
|
3114
|
+
if (xField) {
|
|
3115
|
+
const sums = /* @__PURE__ */ new Map();
|
|
3116
|
+
for (const row of data) {
|
|
3117
|
+
const cat = String(row[xField] ?? "");
|
|
3118
|
+
const val = Number(row[yField] ?? 0);
|
|
3119
|
+
if (Number.isFinite(val) && val > 0) {
|
|
3120
|
+
sums.set(cat, (sums.get(cat) ?? 0) + val);
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
const maxSum = Math.max(...sums.values(), 0);
|
|
3124
|
+
yData = [...data, { [yField]: maxSum }];
|
|
3125
|
+
}
|
|
3126
|
+
}
|
|
3127
|
+
result.y = buildPositionalScale(
|
|
3128
|
+
encoding.y,
|
|
3129
|
+
yData,
|
|
3130
|
+
chartArea.y + chartArea.height,
|
|
3131
|
+
chartArea.y,
|
|
3132
|
+
spec.type,
|
|
3133
|
+
"y"
|
|
3134
|
+
);
|
|
3135
|
+
}
|
|
3136
|
+
if (encoding.color) {
|
|
3137
|
+
const defaultPalette = [
|
|
3138
|
+
"#1b7fa3",
|
|
3139
|
+
"#c44e52",
|
|
3140
|
+
"#6a9f58",
|
|
3141
|
+
"#d47215",
|
|
3142
|
+
"#507e79",
|
|
3143
|
+
"#9a6a8d",
|
|
3144
|
+
"#c4636b",
|
|
3145
|
+
"#9c755f",
|
|
3146
|
+
"#a88f22",
|
|
3147
|
+
"#858078"
|
|
3148
|
+
];
|
|
3149
|
+
if (encoding.color.type === "quantitative") {
|
|
3150
|
+
result.color = buildSequentialColorScale(encoding.color, data, defaultPalette);
|
|
3151
|
+
} else {
|
|
3152
|
+
result.color = buildOrdinalColorScale(encoding.color, data, defaultPalette);
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
return result;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// src/legend/compute.ts
|
|
3159
|
+
import { estimateTextWidth as estimateTextWidth8 } from "@opendata-ai/openchart-core";
|
|
3160
|
+
var SWATCH_SIZE2 = 12;
|
|
3161
|
+
var SWATCH_GAP2 = 6;
|
|
3162
|
+
var ENTRY_GAP2 = 16;
|
|
3163
|
+
var LEGEND_PADDING = 8;
|
|
3164
|
+
var LEGEND_RIGHT_WIDTH = 120;
|
|
3165
|
+
function swatchShapeForType(chartType) {
|
|
3166
|
+
switch (chartType) {
|
|
3167
|
+
case "line":
|
|
3168
|
+
return "line";
|
|
3169
|
+
case "scatter":
|
|
3170
|
+
case "dot":
|
|
3171
|
+
return "circle";
|
|
3172
|
+
default:
|
|
3173
|
+
return "square";
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
function extractColorEntries(spec, theme) {
|
|
3177
|
+
const colorEnc = spec.encoding.color;
|
|
3178
|
+
if (!colorEnc) return [];
|
|
3179
|
+
if (colorEnc.type === "quantitative") return [];
|
|
3180
|
+
const uniqueValues = [...new Set(spec.data.map((d) => String(d[colorEnc.field])))];
|
|
3181
|
+
const palette = theme.colors.categorical;
|
|
3182
|
+
const shape = swatchShapeForType(spec.type);
|
|
3183
|
+
return uniqueValues.map((value, i) => ({
|
|
3184
|
+
label: value,
|
|
3185
|
+
color: palette[i % palette.length],
|
|
3186
|
+
shape,
|
|
3187
|
+
active: true
|
|
3188
|
+
}));
|
|
3189
|
+
}
|
|
3190
|
+
function computeLegend(spec, strategy, theme, chartArea) {
|
|
3191
|
+
const entries = extractColorEntries(spec, theme);
|
|
3192
|
+
const labelStyle = {
|
|
3193
|
+
fontFamily: theme.fonts.family,
|
|
3194
|
+
fontSize: theme.fonts.sizes.small,
|
|
3195
|
+
fontWeight: theme.fonts.weights.normal,
|
|
3196
|
+
fill: theme.colors.text,
|
|
3197
|
+
lineHeight: 1.3
|
|
3198
|
+
};
|
|
3199
|
+
const resolvedPosition = spec.legend?.position ?? (strategy.legendPosition === "right" ? "right" : "top");
|
|
3200
|
+
if (entries.length === 0) {
|
|
3201
|
+
return {
|
|
3202
|
+
position: resolvedPosition,
|
|
3203
|
+
entries: [],
|
|
3204
|
+
bounds: { x: 0, y: 0, width: 0, height: 0 },
|
|
3205
|
+
labelStyle,
|
|
3206
|
+
swatchSize: SWATCH_SIZE2,
|
|
3207
|
+
swatchGap: SWATCH_GAP2,
|
|
3208
|
+
entryGap: ENTRY_GAP2
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
if (resolvedPosition === "right" || resolvedPosition === "bottom-right") {
|
|
3212
|
+
const maxLabelWidth = Math.max(
|
|
3213
|
+
...entries.map((e) => estimateTextWidth8(e.label, labelStyle.fontSize, labelStyle.fontWeight))
|
|
3214
|
+
);
|
|
3215
|
+
const legendWidth = Math.min(
|
|
3216
|
+
LEGEND_RIGHT_WIDTH,
|
|
3217
|
+
SWATCH_SIZE2 + SWATCH_GAP2 + maxLabelWidth + LEGEND_PADDING * 2
|
|
3218
|
+
);
|
|
3219
|
+
const entryHeight = Math.max(SWATCH_SIZE2, labelStyle.fontSize * labelStyle.lineHeight);
|
|
3220
|
+
const legendHeight2 = entries.length * entryHeight + (entries.length - 1) * 4 + LEGEND_PADDING * 2;
|
|
3221
|
+
const clampedHeight = Math.min(legendHeight2, chartArea.height);
|
|
3222
|
+
const legendY = resolvedPosition === "bottom-right" ? chartArea.y + chartArea.height - clampedHeight : chartArea.y;
|
|
3223
|
+
const offsetDx2 = spec.legend?.offset?.dx ?? 0;
|
|
3224
|
+
const offsetDy2 = spec.legend?.offset?.dy ?? 0;
|
|
3225
|
+
return {
|
|
3226
|
+
position: resolvedPosition,
|
|
3227
|
+
entries,
|
|
3228
|
+
bounds: {
|
|
3229
|
+
x: chartArea.x + chartArea.width - legendWidth + offsetDx2,
|
|
3230
|
+
y: legendY + offsetDy2,
|
|
3231
|
+
width: legendWidth,
|
|
3232
|
+
height: clampedHeight
|
|
3233
|
+
},
|
|
3234
|
+
labelStyle,
|
|
3235
|
+
swatchSize: SWATCH_SIZE2,
|
|
3236
|
+
swatchGap: SWATCH_GAP2,
|
|
3237
|
+
entryGap: 4
|
|
3238
|
+
};
|
|
3239
|
+
}
|
|
3240
|
+
const totalWidth = entries.reduce((sum, entry) => {
|
|
3241
|
+
const labelWidth = estimateTextWidth8(entry.label, labelStyle.fontSize, labelStyle.fontWeight);
|
|
3242
|
+
return sum + SWATCH_SIZE2 + SWATCH_GAP2 + labelWidth + ENTRY_GAP2;
|
|
3243
|
+
}, 0);
|
|
3244
|
+
const legendHeight = SWATCH_SIZE2 + LEGEND_PADDING * 2;
|
|
3245
|
+
const offsetDx = spec.legend?.offset?.dx ?? 0;
|
|
3246
|
+
const offsetDy = spec.legend?.offset?.dy ?? 0;
|
|
3247
|
+
return {
|
|
3248
|
+
position: resolvedPosition,
|
|
3249
|
+
entries,
|
|
3250
|
+
bounds: {
|
|
3251
|
+
x: chartArea.x + offsetDx,
|
|
3252
|
+
y: (resolvedPosition === "bottom" ? chartArea.y + chartArea.height - legendHeight : chartArea.y) + offsetDy,
|
|
3253
|
+
width: Math.min(totalWidth, chartArea.width),
|
|
3254
|
+
height: legendHeight
|
|
3255
|
+
},
|
|
3256
|
+
labelStyle,
|
|
3257
|
+
swatchSize: SWATCH_SIZE2,
|
|
3258
|
+
swatchGap: SWATCH_GAP2,
|
|
3259
|
+
entryGap: ENTRY_GAP2
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
|
|
3263
|
+
// src/tables/compile-table.ts
|
|
3264
|
+
import { computeChrome as computeChrome3, estimateTextWidth as estimateTextWidth9 } from "@opendata-ai/openchart-core";
|
|
3265
|
+
|
|
3266
|
+
// src/tables/bar-column.ts
|
|
3267
|
+
var NEGATIVE_BAR_COLOR = "#c44e52";
|
|
3268
|
+
function computeBarCell(value, config, columnMax, columnMin, theme, _darkMode) {
|
|
3269
|
+
const barColor = config.color ?? theme.colors.categorical[0];
|
|
3270
|
+
const hasNegatives = columnMin < 0;
|
|
3271
|
+
if (!Number.isFinite(value)) {
|
|
3272
|
+
return { barPercent: 0, barOffset: 0, barColor, isNegative: false };
|
|
3273
|
+
}
|
|
3274
|
+
if (!hasNegatives) {
|
|
3275
|
+
const maxValue = config.maxValue ?? columnMax;
|
|
3276
|
+
if (maxValue <= 0) {
|
|
3277
|
+
return { barPercent: 0, barOffset: 0, barColor, isNegative: false };
|
|
3278
|
+
}
|
|
3279
|
+
const barPercent2 = Math.max(0, Math.min(1, value / maxValue));
|
|
3280
|
+
return { barPercent: barPercent2, barOffset: 0, barColor, isNegative: false };
|
|
3281
|
+
}
|
|
3282
|
+
const maxPos = config.maxValue ?? columnMax;
|
|
3283
|
+
const absMin = Math.abs(columnMin);
|
|
3284
|
+
const totalRange = maxPos + absMin;
|
|
3285
|
+
if (totalRange === 0) {
|
|
3286
|
+
return { barPercent: 0, barOffset: 0, barColor, isNegative: false };
|
|
3287
|
+
}
|
|
3288
|
+
const zeroPos = absMin / totalRange;
|
|
3289
|
+
if (value >= 0) {
|
|
3290
|
+
const barPercent2 = value / totalRange;
|
|
3291
|
+
return { barPercent: barPercent2, barOffset: zeroPos, barColor, isNegative: false };
|
|
3292
|
+
}
|
|
3293
|
+
const barPercent = Math.abs(value) / totalRange;
|
|
3294
|
+
return {
|
|
3295
|
+
barPercent,
|
|
3296
|
+
barOffset: zeroPos - barPercent,
|
|
3297
|
+
barColor: config.color ?? NEGATIVE_BAR_COLOR,
|
|
3298
|
+
isNegative: true
|
|
3299
|
+
};
|
|
3300
|
+
}
|
|
3301
|
+
function computeColumnMax(data, key) {
|
|
3302
|
+
let max4 = 0;
|
|
3303
|
+
for (const row of data) {
|
|
3304
|
+
const val = row[key];
|
|
3305
|
+
if (typeof val === "number" && Number.isFinite(val) && val > max4) {
|
|
3306
|
+
max4 = val;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
return max4;
|
|
3310
|
+
}
|
|
3311
|
+
function computeColumnMin(data, key) {
|
|
3312
|
+
let min4 = 0;
|
|
3313
|
+
for (const row of data) {
|
|
3314
|
+
const val = row[key];
|
|
3315
|
+
if (typeof val === "number" && Number.isFinite(val) && val < min4) {
|
|
3316
|
+
min4 = val;
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
return min4;
|
|
3320
|
+
}
|
|
3321
|
+
|
|
3322
|
+
// src/tables/category-colors.ts
|
|
3323
|
+
import { adaptColorForDarkMode } from "@opendata-ai/openchart-core";
|
|
3324
|
+
|
|
3325
|
+
// src/tables/utils.ts
|
|
3326
|
+
import { contrastRatio } from "@opendata-ai/openchart-core";
|
|
3327
|
+
function accessibleTextColor(bg) {
|
|
3328
|
+
const white = "#ffffff";
|
|
3329
|
+
const black = "#000000";
|
|
3330
|
+
const whiteRatio = contrastRatio(white, bg);
|
|
3331
|
+
const blackRatio = contrastRatio(black, bg);
|
|
3332
|
+
return whiteRatio >= blackRatio ? white : black;
|
|
3333
|
+
}
|
|
3334
|
+
|
|
3335
|
+
// src/tables/category-colors.ts
|
|
3336
|
+
function computeCategoryColors(data, column, theme, darkMode) {
|
|
3337
|
+
const result = /* @__PURE__ */ new Map();
|
|
3338
|
+
const explicitMap = column.categoryColors;
|
|
3339
|
+
if (!explicitMap) return result;
|
|
3340
|
+
const categoricalPalette = theme.colors.categorical;
|
|
3341
|
+
let nextPaletteIndex = 0;
|
|
3342
|
+
const autoAssigned = /* @__PURE__ */ new Map();
|
|
3343
|
+
const lightBg = "#ffffff";
|
|
3344
|
+
const darkBg = theme.colors.background;
|
|
3345
|
+
for (let i = 0; i < data.length; i++) {
|
|
3346
|
+
const raw = data[i][column.key];
|
|
3347
|
+
if (raw == null) continue;
|
|
3348
|
+
const key = String(raw);
|
|
3349
|
+
let bg;
|
|
3350
|
+
if (explicitMap[key]) {
|
|
3351
|
+
bg = explicitMap[key];
|
|
3352
|
+
} else if (autoAssigned.has(key)) {
|
|
3353
|
+
bg = autoAssigned.get(key);
|
|
3354
|
+
} else {
|
|
3355
|
+
bg = categoricalPalette[nextPaletteIndex % categoricalPalette.length];
|
|
3356
|
+
nextPaletteIndex++;
|
|
3357
|
+
autoAssigned.set(key, bg);
|
|
3358
|
+
}
|
|
3359
|
+
if (darkMode) {
|
|
3360
|
+
bg = adaptColorForDarkMode(bg, lightBg, darkBg);
|
|
3361
|
+
}
|
|
3362
|
+
const textColor = accessibleTextColor(bg);
|
|
3363
|
+
result.set(i, {
|
|
3364
|
+
backgroundColor: bg,
|
|
3365
|
+
color: textColor
|
|
3366
|
+
});
|
|
3367
|
+
}
|
|
3368
|
+
return result;
|
|
3369
|
+
}
|
|
3370
|
+
|
|
3371
|
+
// src/tables/format-cells.ts
|
|
3372
|
+
import { formatDate as formatDate2, formatNumber as formatNumber4 } from "@opendata-ai/openchart-core";
|
|
3373
|
+
import { format as d3Format } from "d3-format";
|
|
3374
|
+
function isNumericValue(value) {
|
|
3375
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
3376
|
+
return false;
|
|
3377
|
+
}
|
|
3378
|
+
function isDateValue(value) {
|
|
3379
|
+
if (value instanceof Date) return !Number.isNaN(value.getTime());
|
|
3380
|
+
return false;
|
|
3381
|
+
}
|
|
3382
|
+
function formatCell(value, column) {
|
|
3383
|
+
const style = {};
|
|
3384
|
+
if (value == null) {
|
|
3385
|
+
return {
|
|
3386
|
+
value,
|
|
3387
|
+
formattedValue: "",
|
|
3388
|
+
style
|
|
3389
|
+
};
|
|
3390
|
+
}
|
|
3391
|
+
if (column.format && isNumericValue(value)) {
|
|
3392
|
+
try {
|
|
3393
|
+
const formatter = d3Format(column.format);
|
|
3394
|
+
return {
|
|
3395
|
+
value,
|
|
3396
|
+
formattedValue: formatter(value),
|
|
3397
|
+
style
|
|
3398
|
+
};
|
|
3399
|
+
} catch {
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
if (isNumericValue(value)) {
|
|
3403
|
+
return {
|
|
3404
|
+
value,
|
|
3405
|
+
formattedValue: formatNumber4(value),
|
|
3406
|
+
style
|
|
3407
|
+
};
|
|
3408
|
+
}
|
|
3409
|
+
if (isDateValue(value)) {
|
|
3410
|
+
return {
|
|
3411
|
+
value,
|
|
3412
|
+
formattedValue: formatDate2(value),
|
|
3413
|
+
style
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
return {
|
|
3417
|
+
value,
|
|
3418
|
+
formattedValue: String(value),
|
|
3419
|
+
style
|
|
3420
|
+
};
|
|
3421
|
+
}
|
|
3422
|
+
function formatValueForSearch(value, column) {
|
|
3423
|
+
if (value == null) return "";
|
|
3424
|
+
if (column.format && isNumericValue(value)) {
|
|
3425
|
+
try {
|
|
3426
|
+
return d3Format(column.format)(value);
|
|
3427
|
+
} catch {
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
if (isNumericValue(value)) {
|
|
3431
|
+
return formatNumber4(value);
|
|
3432
|
+
}
|
|
3433
|
+
return String(value);
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
// src/tables/heatmap.ts
|
|
3437
|
+
import { adaptColorForDarkMode as adaptColorForDarkMode2 } from "@opendata-ai/openchart-core";
|
|
3438
|
+
import { interpolateRgb } from "d3-interpolate";
|
|
3439
|
+
import { scaleSequential } from "d3-scale";
|
|
3440
|
+
function interpolatorFromStops(stops) {
|
|
3441
|
+
if (stops.length === 0) return () => "#ffffff";
|
|
3442
|
+
if (stops.length === 1) return () => stops[0];
|
|
3443
|
+
return (t) => {
|
|
3444
|
+
const clamped = Math.max(0, Math.min(1, t));
|
|
3445
|
+
const segment = clamped * (stops.length - 1);
|
|
3446
|
+
const lo = Math.floor(segment);
|
|
3447
|
+
const hi = Math.min(lo + 1, stops.length - 1);
|
|
3448
|
+
const frac = segment - lo;
|
|
3449
|
+
return interpolateRgb(stops[lo], stops[hi])(frac);
|
|
3450
|
+
};
|
|
3451
|
+
}
|
|
3452
|
+
function resolvePalette(palette, theme) {
|
|
3453
|
+
if (Array.isArray(palette)) return palette;
|
|
3454
|
+
const seqPalettes = theme.colors.sequential;
|
|
3455
|
+
const divPalettes = theme.colors.diverging;
|
|
3456
|
+
if (typeof palette === "string") {
|
|
3457
|
+
if (seqPalettes[palette]) return seqPalettes[palette];
|
|
3458
|
+
if (divPalettes[palette]) return divPalettes[palette];
|
|
3459
|
+
}
|
|
3460
|
+
const firstSeqKey = Object.keys(seqPalettes)[0];
|
|
3461
|
+
return firstSeqKey ? seqPalettes[firstSeqKey] : ["#deebf7", "#08519c"];
|
|
3462
|
+
}
|
|
3463
|
+
function computeHeatmapColors(data, column, theme, darkMode) {
|
|
3464
|
+
const result = /* @__PURE__ */ new Map();
|
|
3465
|
+
const config = column.heatmap;
|
|
3466
|
+
if (!config) return result;
|
|
3467
|
+
const colorField = config.colorByField ?? column.key;
|
|
3468
|
+
const numericValues = [];
|
|
3469
|
+
for (let i = 0; i < data.length; i++) {
|
|
3470
|
+
const raw = data[i][colorField];
|
|
3471
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
3472
|
+
numericValues.push({ index: i, value: raw });
|
|
3473
|
+
}
|
|
3474
|
+
}
|
|
3475
|
+
if (numericValues.length === 0) return result;
|
|
3476
|
+
let domain;
|
|
3477
|
+
if (config.domain) {
|
|
3478
|
+
domain = config.domain;
|
|
3479
|
+
} else {
|
|
3480
|
+
let min4 = Infinity;
|
|
3481
|
+
let max4 = -Infinity;
|
|
3482
|
+
for (const { value } of numericValues) {
|
|
3483
|
+
if (value < min4) min4 = value;
|
|
3484
|
+
if (value > max4) max4 = value;
|
|
3485
|
+
}
|
|
3486
|
+
domain = [min4, max4];
|
|
3487
|
+
}
|
|
3488
|
+
let stops = resolvePalette(config.palette, theme);
|
|
3489
|
+
if (darkMode) {
|
|
3490
|
+
const lightBg = "#ffffff";
|
|
3491
|
+
const darkBg = theme.colors.background;
|
|
3492
|
+
stops = stops.map((c) => adaptColorForDarkMode2(c, lightBg, darkBg));
|
|
3493
|
+
}
|
|
3494
|
+
const interpolator = interpolatorFromStops(stops);
|
|
3495
|
+
const scale = scaleSequential(interpolator).domain(domain).clamp(true);
|
|
3496
|
+
for (const { index, value } of numericValues) {
|
|
3497
|
+
const bg = scale(value);
|
|
3498
|
+
const textColor = accessibleTextColor(bg);
|
|
3499
|
+
result.set(index, {
|
|
3500
|
+
backgroundColor: bg,
|
|
3501
|
+
color: textColor
|
|
3502
|
+
});
|
|
3503
|
+
}
|
|
3504
|
+
return result;
|
|
3505
|
+
}
|
|
3506
|
+
|
|
3507
|
+
// src/tables/pagination.ts
|
|
3508
|
+
function paginateData(data, page, pageSize) {
|
|
3509
|
+
const totalRows = data.length;
|
|
3510
|
+
if (pageSize <= 0) {
|
|
3511
|
+
return {
|
|
3512
|
+
rows: data,
|
|
3513
|
+
totalRows,
|
|
3514
|
+
totalPages: 1,
|
|
3515
|
+
page: 0
|
|
3516
|
+
};
|
|
3517
|
+
}
|
|
3518
|
+
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize));
|
|
3519
|
+
const clampedPage = Math.max(0, Math.min(page, totalPages - 1));
|
|
3520
|
+
const start = clampedPage * pageSize;
|
|
3521
|
+
const end = Math.min(start + pageSize, totalRows);
|
|
3522
|
+
return {
|
|
3523
|
+
rows: data.slice(start, end),
|
|
3524
|
+
totalRows,
|
|
3525
|
+
totalPages,
|
|
3526
|
+
page: clampedPage
|
|
3527
|
+
};
|
|
3528
|
+
}
|
|
3529
|
+
|
|
3530
|
+
// src/tables/search.ts
|
|
3531
|
+
function buildSearchIndex(data, columns) {
|
|
3532
|
+
const index = /* @__PURE__ */ new Map();
|
|
3533
|
+
for (let i = 0; i < data.length; i++) {
|
|
3534
|
+
const row = data[i];
|
|
3535
|
+
const parts = [];
|
|
3536
|
+
for (const col of columns) {
|
|
3537
|
+
parts.push(formatValueForSearch(row[col.key], col));
|
|
3538
|
+
}
|
|
3539
|
+
index.set(i, parts.join(" ").toLowerCase());
|
|
3540
|
+
}
|
|
3541
|
+
return index;
|
|
3542
|
+
}
|
|
3543
|
+
function filterBySearch(data, query, searchIndex, originalIndices) {
|
|
3544
|
+
if (!query || query.trim() === "") {
|
|
3545
|
+
return { data, indices: originalIndices };
|
|
3546
|
+
}
|
|
3547
|
+
const lowerQuery = query.toLowerCase();
|
|
3548
|
+
const filteredData = [];
|
|
3549
|
+
const filteredIndices = [];
|
|
3550
|
+
for (let i = 0; i < data.length; i++) {
|
|
3551
|
+
const originalIdx = originalIndices[i];
|
|
3552
|
+
const searchText = searchIndex.get(originalIdx);
|
|
3553
|
+
if (searchText?.includes(lowerQuery)) {
|
|
3554
|
+
filteredData.push(data[i]);
|
|
3555
|
+
filteredIndices.push(originalIdx);
|
|
3556
|
+
}
|
|
3557
|
+
}
|
|
3558
|
+
return { data: filteredData, indices: filteredIndices };
|
|
3559
|
+
}
|
|
3560
|
+
|
|
3561
|
+
// src/tables/sort.ts
|
|
3562
|
+
function sortData(data, sort) {
|
|
3563
|
+
const { column, direction } = sort;
|
|
3564
|
+
const multiplier = direction === "asc" ? 1 : -1;
|
|
3565
|
+
const indexed = data.map((row, i) => ({ row, index: i }));
|
|
3566
|
+
indexed.sort((a, b) => {
|
|
3567
|
+
const aVal = a.row[column];
|
|
3568
|
+
const bVal = b.row[column];
|
|
3569
|
+
const aNull = aVal == null;
|
|
3570
|
+
const bNull = bVal == null;
|
|
3571
|
+
if (aNull && bNull) return a.index - b.index;
|
|
3572
|
+
if (aNull) return 1;
|
|
3573
|
+
if (bNull) return -1;
|
|
3574
|
+
let cmp = 0;
|
|
3575
|
+
if (typeof aVal === "number" && typeof bVal === "number") {
|
|
3576
|
+
cmp = aVal - bVal;
|
|
3577
|
+
} else if (aVal instanceof Date && bVal instanceof Date) {
|
|
3578
|
+
cmp = aVal.getTime() - bVal.getTime();
|
|
3579
|
+
} else {
|
|
3580
|
+
cmp = String(aVal).localeCompare(String(bVal));
|
|
3581
|
+
}
|
|
3582
|
+
if (cmp === 0) return a.index - b.index;
|
|
3583
|
+
return cmp * multiplier;
|
|
3584
|
+
});
|
|
3585
|
+
return {
|
|
3586
|
+
data: indexed.map((item) => item.row),
|
|
3587
|
+
originalIndices: indexed.map((item) => item.index)
|
|
3588
|
+
};
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
// src/tables/sparkline.ts
|
|
3592
|
+
function extractValues(row, columnKey, config) {
|
|
3593
|
+
const field = config.valuesField ?? columnKey;
|
|
3594
|
+
const raw = row[field];
|
|
3595
|
+
if (!Array.isArray(raw)) return [];
|
|
3596
|
+
return raw.map((v) => typeof v === "number" && Number.isFinite(v) ? v : null).filter((v) => v !== null);
|
|
3597
|
+
}
|
|
3598
|
+
function computeSparkline(values, config, theme, _darkMode) {
|
|
3599
|
+
if (values.length === 0) return null;
|
|
3600
|
+
const type = config.type ?? "line";
|
|
3601
|
+
const color = config.color ?? theme.colors.categorical[0];
|
|
3602
|
+
let min4 = Infinity;
|
|
3603
|
+
let max4 = -Infinity;
|
|
3604
|
+
for (const v of values) {
|
|
3605
|
+
if (v < min4) min4 = v;
|
|
3606
|
+
if (v > max4) max4 = v;
|
|
3607
|
+
}
|
|
3608
|
+
const range = max4 - min4;
|
|
3609
|
+
const normalize = (v) => range === 0 ? 0.5 : (v - min4) / range;
|
|
3610
|
+
const startValue = values[0];
|
|
3611
|
+
const endValue = values[values.length - 1];
|
|
3612
|
+
if (type === "line") {
|
|
3613
|
+
const points2 = values.map((v, i) => ({
|
|
3614
|
+
x: values.length === 1 ? 0.5 : i / (values.length - 1),
|
|
3615
|
+
y: normalize(v)
|
|
3616
|
+
}));
|
|
3617
|
+
return {
|
|
3618
|
+
type,
|
|
3619
|
+
points: points2,
|
|
3620
|
+
bars: [],
|
|
3621
|
+
color,
|
|
3622
|
+
count: values.length,
|
|
3623
|
+
startValue,
|
|
3624
|
+
endValue
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
const bars = values.map(normalize);
|
|
3628
|
+
const points = values.map((v, i) => ({
|
|
3629
|
+
x: values.length === 1 ? 0.5 : i / (values.length - 1),
|
|
3630
|
+
y: normalize(v)
|
|
3631
|
+
}));
|
|
3632
|
+
return {
|
|
3633
|
+
type,
|
|
3634
|
+
points,
|
|
3635
|
+
bars,
|
|
3636
|
+
color,
|
|
3637
|
+
count: values.length,
|
|
3638
|
+
startValue,
|
|
3639
|
+
endValue
|
|
3640
|
+
};
|
|
3641
|
+
}
|
|
3642
|
+
function computeSparklineForRow(row, columnKey, config, theme, darkMode) {
|
|
3643
|
+
const values = extractValues(row, columnKey, config);
|
|
3644
|
+
return computeSparkline(values, config, theme, darkMode);
|
|
3645
|
+
}
|
|
3646
|
+
|
|
3647
|
+
// src/tables/compile-table.ts
|
|
3648
|
+
function determineCellType(col) {
|
|
3649
|
+
if (col.sparkline) return "sparkline";
|
|
3650
|
+
if (col.bar) return "bar";
|
|
3651
|
+
if (col.heatmap) return "heatmap";
|
|
3652
|
+
if (col.image) return "image";
|
|
3653
|
+
if (col.flag) return "flag";
|
|
3654
|
+
if (col.categoryColors) return "category";
|
|
3655
|
+
return "text";
|
|
3656
|
+
}
|
|
3657
|
+
function inferAlignment(col, data) {
|
|
3658
|
+
if (col.align) return col.align;
|
|
3659
|
+
for (const row of data) {
|
|
3660
|
+
const val = row[col.key];
|
|
3661
|
+
if (val != null) {
|
|
3662
|
+
return typeof val === "number" ? "right" : "left";
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
return "left";
|
|
3666
|
+
}
|
|
3667
|
+
function estimateColumnWidth(col, data, fontSize) {
|
|
3668
|
+
const MIN_WIDTH = 60;
|
|
3669
|
+
const PADDING = 24;
|
|
3670
|
+
if (col.sparkline) return 140;
|
|
3671
|
+
if (col.image) return (col.image.width ?? 24) + PADDING;
|
|
3672
|
+
if (col.flag) return 60;
|
|
3673
|
+
const label = col.label ?? col.key;
|
|
3674
|
+
const headerWidth = estimateTextWidth9(label, fontSize, 600) + PADDING;
|
|
3675
|
+
const sampleSize = Math.min(100, data.length);
|
|
3676
|
+
let maxDataWidth = 0;
|
|
3677
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
3678
|
+
const val = data[i][col.key];
|
|
3679
|
+
const text = val == null ? "" : String(val);
|
|
3680
|
+
const width = estimateTextWidth9(text, fontSize, 400) + PADDING;
|
|
3681
|
+
if (width > maxDataWidth) maxDataWidth = width;
|
|
3682
|
+
}
|
|
3683
|
+
return Math.max(MIN_WIDTH, headerWidth, maxDataWidth);
|
|
3684
|
+
}
|
|
3685
|
+
function resolveColumns(columns, data, totalWidth, theme) {
|
|
3686
|
+
const fontSize = theme.fonts.sizes.body;
|
|
3687
|
+
const isFixed = columns.map((col) => !!(col.sparkline || col.image || col.flag));
|
|
3688
|
+
const naturalWidths = columns.map((col) => {
|
|
3689
|
+
if (col.width) {
|
|
3690
|
+
if (col.width.endsWith("px")) {
|
|
3691
|
+
return parseInt(col.width, 10) || 100;
|
|
3692
|
+
}
|
|
3693
|
+
if (col.width.endsWith("%")) {
|
|
3694
|
+
return parseFloat(col.width) / 100 * totalWidth || 100;
|
|
3695
|
+
}
|
|
3696
|
+
return parseInt(col.width, 10) || 100;
|
|
3697
|
+
}
|
|
3698
|
+
return estimateColumnWidth(col, data, fontSize);
|
|
3699
|
+
});
|
|
3700
|
+
const fixedTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? w : 0), 0);
|
|
3701
|
+
const flexTotal = naturalWidths.reduce((sum, w, i) => sum + (isFixed[i] ? 0 : w), 0);
|
|
3702
|
+
const remainingWidth = totalWidth - fixedTotal;
|
|
3703
|
+
const flexScale = flexTotal > 0 && remainingWidth > 0 ? remainingWidth / flexTotal : 1;
|
|
3704
|
+
return columns.map((col, i) => ({
|
|
3705
|
+
key: col.key,
|
|
3706
|
+
label: col.label ?? col.key,
|
|
3707
|
+
width: Math.max(60, isFixed[i] ? naturalWidths[i] : Math.round(naturalWidths[i] * flexScale)),
|
|
3708
|
+
sortable: col.sortable ?? true,
|
|
3709
|
+
align: inferAlignment(col, data),
|
|
3710
|
+
cellType: determineCellType(col)
|
|
3711
|
+
}));
|
|
3712
|
+
}
|
|
3713
|
+
function buildCell(value, column, resolvedColumn, heatmapStyle, categoryStyle, barData, sparklineData) {
|
|
3714
|
+
const base = formatCell(value, column);
|
|
3715
|
+
if (typeof value === "number") {
|
|
3716
|
+
base.style = { ...base.style, fontVariant: "tabular-nums" };
|
|
3717
|
+
}
|
|
3718
|
+
const cellType = resolvedColumn.cellType;
|
|
3719
|
+
switch (cellType) {
|
|
3720
|
+
case "heatmap": {
|
|
3721
|
+
const merged = heatmapStyle ? { ...base.style, ...heatmapStyle } : base.style;
|
|
3722
|
+
return {
|
|
3723
|
+
...base,
|
|
3724
|
+
cellType: "heatmap",
|
|
3725
|
+
style: merged
|
|
3726
|
+
};
|
|
3727
|
+
}
|
|
3728
|
+
case "category": {
|
|
3729
|
+
const merged = categoryStyle ? { ...base.style, ...categoryStyle } : base.style;
|
|
3730
|
+
return {
|
|
3731
|
+
...base,
|
|
3732
|
+
cellType: "category",
|
|
3733
|
+
style: merged
|
|
3734
|
+
};
|
|
3735
|
+
}
|
|
3736
|
+
case "bar": {
|
|
3737
|
+
return {
|
|
3738
|
+
...base,
|
|
3739
|
+
cellType: "bar",
|
|
3740
|
+
barWidth: barData?.barPercent ?? 0,
|
|
3741
|
+
barOffset: barData?.barOffset ?? 0,
|
|
3742
|
+
barColor: barData?.barColor ?? "#ccc",
|
|
3743
|
+
isNegative: barData?.isNegative ?? false
|
|
3744
|
+
};
|
|
3745
|
+
}
|
|
3746
|
+
case "sparkline": {
|
|
3747
|
+
return {
|
|
3748
|
+
...base,
|
|
3749
|
+
cellType: "sparkline",
|
|
3750
|
+
sparklineData
|
|
3751
|
+
};
|
|
3752
|
+
}
|
|
3753
|
+
case "image": {
|
|
3754
|
+
const src = typeof value === "string" ? value : "";
|
|
3755
|
+
const imgConfig = column.image ?? {};
|
|
3756
|
+
return {
|
|
3757
|
+
...base,
|
|
3758
|
+
cellType: "image",
|
|
3759
|
+
src,
|
|
3760
|
+
imageWidth: imgConfig.width ?? 24,
|
|
3761
|
+
imageHeight: imgConfig.height ?? 24,
|
|
3762
|
+
rounded: imgConfig.rounded ?? false
|
|
3763
|
+
};
|
|
3764
|
+
}
|
|
3765
|
+
case "flag": {
|
|
3766
|
+
const code = typeof value === "string" ? value : "";
|
|
3767
|
+
return {
|
|
3768
|
+
...base,
|
|
3769
|
+
cellType: "flag",
|
|
3770
|
+
countryCode: code
|
|
3771
|
+
};
|
|
3772
|
+
}
|
|
3773
|
+
default: {
|
|
3774
|
+
return {
|
|
3775
|
+
...base,
|
|
3776
|
+
cellType: "text"
|
|
3777
|
+
};
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
}
|
|
3781
|
+
function compileTableLayout(spec, options, theme) {
|
|
3782
|
+
const data = spec.data;
|
|
3783
|
+
const darkMode = theme.isDark;
|
|
3784
|
+
const resolvedColumns = resolveColumns(spec.columns, data, options.width, theme);
|
|
3785
|
+
const searchIndex = spec.search ? buildSearchIndex(data, spec.columns) : /* @__PURE__ */ new Map();
|
|
3786
|
+
let currentData = data;
|
|
3787
|
+
let originalIndices = data.map((_, i) => i);
|
|
3788
|
+
if (options.sort) {
|
|
3789
|
+
const sorted = sortData(currentData, options.sort);
|
|
3790
|
+
originalIndices = sorted.originalIndices.map((i) => originalIndices[i]);
|
|
3791
|
+
currentData = sorted.data;
|
|
3792
|
+
}
|
|
3793
|
+
if (spec.search && options.search) {
|
|
3794
|
+
const filtered = filterBySearch(currentData, options.search, searchIndex, originalIndices);
|
|
3795
|
+
currentData = filtered.data;
|
|
3796
|
+
originalIndices = filtered.indices;
|
|
3797
|
+
}
|
|
3798
|
+
const totalFiltered = currentData.length;
|
|
3799
|
+
let pageSize = 0;
|
|
3800
|
+
let currentPage = 0;
|
|
3801
|
+
let paginationState;
|
|
3802
|
+
if (spec.pagination) {
|
|
3803
|
+
pageSize = options.pageSize ?? (typeof spec.pagination === "object" ? spec.pagination.pageSize : 25);
|
|
3804
|
+
currentPage = options.page ?? 0;
|
|
3805
|
+
const paginated = paginateData(currentData, currentPage, pageSize);
|
|
3806
|
+
const start = paginated.page * pageSize;
|
|
3807
|
+
const end = start + paginated.rows.length;
|
|
3808
|
+
const pageIndices = originalIndices.slice(start, end);
|
|
3809
|
+
currentData = paginated.rows;
|
|
3810
|
+
originalIndices = pageIndices;
|
|
3811
|
+
paginationState = {
|
|
3812
|
+
page: paginated.page,
|
|
3813
|
+
pageSize,
|
|
3814
|
+
totalRows: paginated.totalRows,
|
|
3815
|
+
totalPages: paginated.totalPages
|
|
3816
|
+
};
|
|
3817
|
+
}
|
|
3818
|
+
const heatmapMaps = /* @__PURE__ */ new Map();
|
|
3819
|
+
const categoryMaps = /* @__PURE__ */ new Map();
|
|
3820
|
+
const barMaxes = /* @__PURE__ */ new Map();
|
|
3821
|
+
const barMins = /* @__PURE__ */ new Map();
|
|
3822
|
+
for (let c = 0; c < spec.columns.length; c++) {
|
|
3823
|
+
const col = spec.columns[c];
|
|
3824
|
+
const resolved = resolvedColumns[c];
|
|
3825
|
+
if (resolved.cellType === "heatmap" && col.heatmap) {
|
|
3826
|
+
heatmapMaps.set(col.key, computeHeatmapColors(data, col, theme, darkMode));
|
|
3827
|
+
}
|
|
3828
|
+
if (resolved.cellType === "category" && col.categoryColors) {
|
|
3829
|
+
categoryMaps.set(col.key, computeCategoryColors(data, col, theme, darkMode));
|
|
3830
|
+
}
|
|
3831
|
+
if (resolved.cellType === "bar" && col.bar) {
|
|
3832
|
+
barMaxes.set(col.key, computeColumnMax(data, col.key));
|
|
3833
|
+
barMins.set(col.key, computeColumnMin(data, col.key));
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
const rows = currentData.map((row, i) => {
|
|
3837
|
+
const origIdx = originalIndices[i];
|
|
3838
|
+
const rowId = spec.rowKey ? String(row[spec.rowKey] ?? origIdx) : String(origIdx);
|
|
3839
|
+
const cells = spec.columns.map((col, c) => {
|
|
3840
|
+
const resolved = resolvedColumns[c];
|
|
3841
|
+
const value = row[col.key];
|
|
3842
|
+
const heatmapStyle = heatmapMaps.get(col.key)?.get(origIdx);
|
|
3843
|
+
const categoryStyle = categoryMaps.get(col.key)?.get(origIdx);
|
|
3844
|
+
let barData;
|
|
3845
|
+
if (resolved.cellType === "bar" && col.bar && typeof value === "number") {
|
|
3846
|
+
barData = computeBarCell(
|
|
3847
|
+
value,
|
|
3848
|
+
col.bar,
|
|
3849
|
+
barMaxes.get(col.key) ?? 0,
|
|
3850
|
+
barMins.get(col.key) ?? 0,
|
|
3851
|
+
theme,
|
|
3852
|
+
darkMode
|
|
3853
|
+
);
|
|
3854
|
+
}
|
|
3855
|
+
let sparklineData = null;
|
|
3856
|
+
if (resolved.cellType === "sparkline" && col.sparkline) {
|
|
3857
|
+
sparklineData = computeSparklineForRow(row, col.key, col.sparkline, theme, darkMode);
|
|
3858
|
+
}
|
|
3859
|
+
return buildCell(value, col, resolved, heatmapStyle, categoryStyle, barData, sparklineData);
|
|
3860
|
+
});
|
|
3861
|
+
return { id: rowId, cells, data: row };
|
|
3862
|
+
});
|
|
3863
|
+
const chrome = computeChrome3(
|
|
3864
|
+
{
|
|
3865
|
+
title: spec.chrome.title,
|
|
3866
|
+
subtitle: spec.chrome.subtitle,
|
|
3867
|
+
source: spec.chrome.source,
|
|
3868
|
+
byline: spec.chrome.byline,
|
|
3869
|
+
footer: spec.chrome.footer
|
|
3870
|
+
},
|
|
3871
|
+
theme,
|
|
3872
|
+
options.width,
|
|
3873
|
+
options.measureText
|
|
3874
|
+
);
|
|
3875
|
+
const titleText = spec.chrome.title?.text ?? "";
|
|
3876
|
+
const caption = titleText ? `Table: ${titleText}` : `Data table with ${data.length} rows`;
|
|
3877
|
+
return {
|
|
3878
|
+
chrome,
|
|
3879
|
+
columns: resolvedColumns,
|
|
3880
|
+
rows,
|
|
3881
|
+
sort: options.sort,
|
|
3882
|
+
pagination: paginationState,
|
|
3883
|
+
search: {
|
|
3884
|
+
enabled: spec.search,
|
|
3885
|
+
placeholder: "Search...",
|
|
3886
|
+
query: options.search ?? ""
|
|
3887
|
+
},
|
|
3888
|
+
stickyFirstColumn: spec.stickyFirstColumn,
|
|
3889
|
+
compact: spec.compact,
|
|
3890
|
+
a11y: {
|
|
3891
|
+
caption,
|
|
3892
|
+
summary: `${resolvedColumns.length} columns, ${totalFiltered} rows`
|
|
3893
|
+
},
|
|
3894
|
+
theme
|
|
3895
|
+
};
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
// src/tooltips/compute.ts
|
|
3899
|
+
import { formatDate as formatDate3, formatNumber as formatNumber5 } from "@opendata-ai/openchart-core";
|
|
3900
|
+
import { format as d3Format2 } from "d3-format";
|
|
3901
|
+
function formatValue(value, fieldType, format) {
|
|
3902
|
+
if (value == null) return "";
|
|
3903
|
+
if (fieldType === "temporal" || value instanceof Date) {
|
|
3904
|
+
return formatDate3(value);
|
|
3905
|
+
}
|
|
3906
|
+
if (typeof value === "number") {
|
|
3907
|
+
if (format) {
|
|
3908
|
+
try {
|
|
3909
|
+
return d3Format2(format)(value);
|
|
3910
|
+
} catch {
|
|
3911
|
+
return formatNumber5(value);
|
|
3912
|
+
}
|
|
3913
|
+
}
|
|
3914
|
+
return formatNumber5(value);
|
|
3915
|
+
}
|
|
3916
|
+
return String(value);
|
|
3917
|
+
}
|
|
3918
|
+
function buildFields(row, encoding, color) {
|
|
3919
|
+
const fields = [];
|
|
3920
|
+
if (encoding.y) {
|
|
3921
|
+
fields.push({
|
|
3922
|
+
label: encoding.y.axis?.label ?? encoding.y.field,
|
|
3923
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
|
|
3924
|
+
color
|
|
3925
|
+
});
|
|
3926
|
+
}
|
|
3927
|
+
if (encoding.x) {
|
|
3928
|
+
fields.push({
|
|
3929
|
+
label: encoding.x.axis?.label ?? encoding.x.field,
|
|
3930
|
+
value: formatValue(row[encoding.x.field], encoding.x.type, encoding.x.axis?.format)
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
if (encoding.size) {
|
|
3934
|
+
fields.push({
|
|
3935
|
+
label: encoding.size.axis?.label ?? encoding.size.field,
|
|
3936
|
+
value: formatValue(row[encoding.size.field], encoding.size.type, encoding.size.axis?.format)
|
|
3937
|
+
});
|
|
3938
|
+
}
|
|
3939
|
+
return fields;
|
|
3940
|
+
}
|
|
3941
|
+
function getTooltipTitle(row, encoding) {
|
|
3942
|
+
if (encoding.x?.type === "temporal") {
|
|
3943
|
+
return formatValue(row[encoding.x.field], "temporal");
|
|
3944
|
+
}
|
|
3945
|
+
if (encoding.x?.type === "nominal" || encoding.x?.type === "ordinal") {
|
|
3946
|
+
return String(row[encoding.x.field] ?? "");
|
|
3947
|
+
}
|
|
3948
|
+
if (encoding.y?.type === "nominal" || encoding.y?.type === "ordinal") {
|
|
3949
|
+
return String(row[encoding.y.field] ?? "");
|
|
3950
|
+
}
|
|
3951
|
+
if (encoding.color) {
|
|
3952
|
+
return String(row[encoding.color.field] ?? "");
|
|
3953
|
+
}
|
|
3954
|
+
return void 0;
|
|
3955
|
+
}
|
|
3956
|
+
function tooltipsForLine(_mark, _encoding, _markIndex) {
|
|
3957
|
+
return [];
|
|
3958
|
+
}
|
|
3959
|
+
function tooltipsForPoint(mark, encoding, markIndex) {
|
|
3960
|
+
const title = getTooltipTitle(mark.data, encoding);
|
|
3961
|
+
const fields = buildFields(mark.data, encoding, mark.fill);
|
|
3962
|
+
return [[`point-${markIndex}`, { title, fields }]];
|
|
3963
|
+
}
|
|
3964
|
+
function tooltipsForRect(mark, encoding, markIndex) {
|
|
3965
|
+
const title = getTooltipTitle(mark.data, encoding);
|
|
3966
|
+
const fields = buildFields(mark.data, encoding, mark.fill);
|
|
3967
|
+
return [[`rect-${markIndex}`, { title, fields }]];
|
|
3968
|
+
}
|
|
3969
|
+
function tooltipsForArc(mark, encoding, markIndex) {
|
|
3970
|
+
const row = mark.data;
|
|
3971
|
+
const fields = [];
|
|
3972
|
+
if (encoding.color) {
|
|
3973
|
+
const categoryName = String(row[encoding.color.field] ?? "");
|
|
3974
|
+
if (encoding.y) {
|
|
3975
|
+
fields.push({
|
|
3976
|
+
label: categoryName,
|
|
3977
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
|
|
3978
|
+
color: mark.fill
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
} else if (encoding.y) {
|
|
3982
|
+
fields.push({
|
|
3983
|
+
label: encoding.y.field,
|
|
3984
|
+
value: formatValue(row[encoding.y.field], encoding.y.type, encoding.y.axis?.format),
|
|
3985
|
+
color: mark.fill
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
const title = encoding.color ? String(row[encoding.color.field] ?? "") : void 0;
|
|
3989
|
+
return [[`arc-${markIndex}`, { title, fields }]];
|
|
3990
|
+
}
|
|
3991
|
+
function tooltipsForArea(_mark, _encoding, _markIndex) {
|
|
3992
|
+
return [];
|
|
3993
|
+
}
|
|
3994
|
+
function computeTooltipDescriptors(spec, marks) {
|
|
3995
|
+
const encoding = spec.encoding;
|
|
3996
|
+
const descriptors = /* @__PURE__ */ new Map();
|
|
3997
|
+
for (let i = 0; i < marks.length; i++) {
|
|
3998
|
+
const mark = marks[i];
|
|
3999
|
+
let entries = [];
|
|
4000
|
+
switch (mark.type) {
|
|
4001
|
+
case "line":
|
|
4002
|
+
entries = tooltipsForLine(mark, encoding, i);
|
|
4003
|
+
break;
|
|
4004
|
+
case "area":
|
|
4005
|
+
entries = tooltipsForArea(mark, encoding, i);
|
|
4006
|
+
break;
|
|
4007
|
+
case "point":
|
|
4008
|
+
entries = tooltipsForPoint(mark, encoding, i);
|
|
4009
|
+
break;
|
|
4010
|
+
case "rect":
|
|
4011
|
+
entries = tooltipsForRect(mark, encoding, i);
|
|
4012
|
+
break;
|
|
4013
|
+
case "arc":
|
|
4014
|
+
entries = tooltipsForArc(mark, encoding, i);
|
|
4015
|
+
break;
|
|
4016
|
+
}
|
|
4017
|
+
for (const [key, content] of entries) {
|
|
4018
|
+
descriptors.set(key, content);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
return descriptors;
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
// src/compile.ts
|
|
4025
|
+
var builtinRenderers = {
|
|
4026
|
+
line: lineRenderer,
|
|
4027
|
+
area: areaRenderer,
|
|
4028
|
+
bar: barRenderer,
|
|
4029
|
+
column: columnRenderer,
|
|
4030
|
+
scatter: scatterRenderer,
|
|
4031
|
+
pie: pieRenderer,
|
|
4032
|
+
donut: donutRenderer,
|
|
4033
|
+
dot: dotRenderer
|
|
4034
|
+
};
|
|
4035
|
+
for (const [type, renderer] of Object.entries(builtinRenderers)) {
|
|
4036
|
+
registerChartRenderer(type, renderer);
|
|
4037
|
+
}
|
|
4038
|
+
function computeRowObstacles(marks, scales) {
|
|
4039
|
+
if (!scales.y || scales.y.type !== "band") return [];
|
|
4040
|
+
const rows = /* @__PURE__ */ new Map();
|
|
4041
|
+
for (const mark of marks) {
|
|
4042
|
+
let cy;
|
|
4043
|
+
let left;
|
|
4044
|
+
let right;
|
|
4045
|
+
if (mark.type === "point") {
|
|
4046
|
+
const pm = mark;
|
|
4047
|
+
cy = pm.cy;
|
|
4048
|
+
left = pm.cx - pm.r;
|
|
4049
|
+
right = pm.cx + pm.r;
|
|
4050
|
+
} else if (mark.type === "rect") {
|
|
4051
|
+
const rm = mark;
|
|
4052
|
+
cy = rm.y + rm.height / 2;
|
|
4053
|
+
left = rm.x;
|
|
4054
|
+
right = rm.x + rm.width;
|
|
4055
|
+
} else {
|
|
4056
|
+
continue;
|
|
4057
|
+
}
|
|
4058
|
+
const key = Math.round(cy);
|
|
4059
|
+
const existing = rows.get(key);
|
|
4060
|
+
if (existing) {
|
|
4061
|
+
existing.minX = Math.min(existing.minX, left);
|
|
4062
|
+
existing.maxX = Math.max(existing.maxX, right);
|
|
4063
|
+
} else {
|
|
4064
|
+
rows.set(key, { minX: left, maxX: right, bandY: cy });
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
const bandScale = scales.y.scale;
|
|
4068
|
+
const bandwidth = bandScale.bandwidth?.() ?? 0;
|
|
4069
|
+
if (bandwidth === 0) return [];
|
|
4070
|
+
const obstacles = [];
|
|
4071
|
+
for (const { minX, maxX, bandY } of rows.values()) {
|
|
4072
|
+
obstacles.push({
|
|
4073
|
+
x: minX,
|
|
4074
|
+
y: bandY - bandwidth / 2,
|
|
4075
|
+
width: maxX - minX,
|
|
4076
|
+
height: bandwidth
|
|
4077
|
+
});
|
|
4078
|
+
}
|
|
4079
|
+
return obstacles;
|
|
4080
|
+
}
|
|
4081
|
+
function compileChart(spec, options) {
|
|
4082
|
+
const { spec: normalized } = compile(spec);
|
|
4083
|
+
if (normalized.type === "table") {
|
|
4084
|
+
throw new Error("compileChart received a table spec. Use compileTable instead.");
|
|
4085
|
+
}
|
|
4086
|
+
if (normalized.type === "graph") {
|
|
4087
|
+
throw new Error("compileChart received a graph spec. Use compileGraph instead.");
|
|
4088
|
+
}
|
|
4089
|
+
const chartSpec = normalized;
|
|
4090
|
+
const mergedThemeConfig = options.theme ? { ...chartSpec.theme, ...options.theme } : chartSpec.theme;
|
|
4091
|
+
let theme = resolveTheme2(mergedThemeConfig);
|
|
4092
|
+
if (options.darkMode) {
|
|
4093
|
+
theme = adaptTheme2(theme);
|
|
4094
|
+
}
|
|
4095
|
+
const breakpoint = getBreakpoint(options.width);
|
|
4096
|
+
const strategy = getLayoutStrategy(breakpoint);
|
|
4097
|
+
const preliminaryArea = {
|
|
4098
|
+
x: 0,
|
|
4099
|
+
y: 0,
|
|
4100
|
+
width: options.width,
|
|
4101
|
+
height: options.height
|
|
4102
|
+
};
|
|
4103
|
+
const legendLayout = computeLegend(chartSpec, strategy, theme, preliminaryArea);
|
|
4104
|
+
const dims = computeDimensions(chartSpec, options, legendLayout, theme);
|
|
4105
|
+
const chartArea = dims.chartArea;
|
|
4106
|
+
const legendArea = { ...chartArea };
|
|
4107
|
+
if (legendLayout.entries.length > 0) {
|
|
4108
|
+
switch (legendLayout.position) {
|
|
4109
|
+
case "top":
|
|
4110
|
+
legendArea.y -= legendLayout.bounds.height + 4;
|
|
4111
|
+
legendArea.height += legendLayout.bounds.height + 4;
|
|
4112
|
+
break;
|
|
4113
|
+
case "bottom":
|
|
4114
|
+
legendArea.height += legendLayout.bounds.height + 4;
|
|
4115
|
+
break;
|
|
4116
|
+
case "right":
|
|
4117
|
+
case "bottom-right":
|
|
4118
|
+
legendArea.width += legendLayout.bounds.width + 8;
|
|
4119
|
+
break;
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
const finalLegend = computeLegend(chartSpec, strategy, theme, legendArea);
|
|
4123
|
+
const scales = computeScales(chartSpec, chartArea, chartSpec.data);
|
|
4124
|
+
if (scales.color) {
|
|
4125
|
+
if (scales.color.type === "sequential") {
|
|
4126
|
+
const seqStops = Object.values(theme.colors.sequential)[0] ?? theme.colors.categorical;
|
|
4127
|
+
scales.color.scale.range([
|
|
4128
|
+
seqStops[0],
|
|
4129
|
+
seqStops[seqStops.length - 1]
|
|
4130
|
+
]);
|
|
4131
|
+
} else {
|
|
4132
|
+
scales.color.scale.range(
|
|
4133
|
+
theme.colors.categorical
|
|
4134
|
+
);
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4137
|
+
scales.defaultColor = theme.colors.categorical[0];
|
|
4138
|
+
const isRadial = chartSpec.type === "pie" || chartSpec.type === "donut";
|
|
4139
|
+
const axes = isRadial ? { x: void 0, y: void 0 } : computeAxes(scales, chartArea, strategy, theme);
|
|
4140
|
+
if (!isRadial) {
|
|
4141
|
+
computeGridlines(axes, chartArea);
|
|
4142
|
+
}
|
|
4143
|
+
const renderer = getChartRenderer(chartSpec.type);
|
|
4144
|
+
const marks = renderer ? renderer(chartSpec, scales, chartArea, strategy, theme) : [];
|
|
4145
|
+
const obstacles = [];
|
|
4146
|
+
if (finalLegend.bounds.width > 0) {
|
|
4147
|
+
obstacles.push(finalLegend.bounds);
|
|
4148
|
+
}
|
|
4149
|
+
obstacles.push(...computeRowObstacles(marks, scales));
|
|
4150
|
+
const annotations = computeAnnotations(
|
|
4151
|
+
chartSpec,
|
|
4152
|
+
scales,
|
|
4153
|
+
chartArea,
|
|
4154
|
+
strategy,
|
|
4155
|
+
theme.isDark,
|
|
4156
|
+
obstacles
|
|
4157
|
+
);
|
|
4158
|
+
const tooltipDescriptors = computeTooltipDescriptors(chartSpec, marks);
|
|
4159
|
+
const altText = generateAltText(
|
|
4160
|
+
{
|
|
4161
|
+
type: chartSpec.type,
|
|
4162
|
+
data: chartSpec.data,
|
|
4163
|
+
encoding: chartSpec.encoding,
|
|
4164
|
+
chrome: chartSpec.chrome
|
|
4165
|
+
},
|
|
4166
|
+
chartSpec.data
|
|
4167
|
+
);
|
|
4168
|
+
const dataTableFallback = generateDataTable(
|
|
4169
|
+
{
|
|
4170
|
+
type: chartSpec.type,
|
|
4171
|
+
data: chartSpec.data,
|
|
4172
|
+
encoding: chartSpec.encoding
|
|
4173
|
+
},
|
|
4174
|
+
chartSpec.data
|
|
4175
|
+
);
|
|
4176
|
+
return {
|
|
4177
|
+
area: chartArea,
|
|
4178
|
+
chrome: dims.chrome,
|
|
4179
|
+
axes: {
|
|
4180
|
+
x: axes.x,
|
|
4181
|
+
y: axes.y
|
|
4182
|
+
},
|
|
4183
|
+
marks,
|
|
4184
|
+
annotations,
|
|
4185
|
+
legend: finalLegend,
|
|
4186
|
+
tooltipDescriptors,
|
|
4187
|
+
a11y: {
|
|
4188
|
+
altText,
|
|
4189
|
+
dataTableFallback,
|
|
4190
|
+
role: "img",
|
|
4191
|
+
keyboardNavigable: marks.length > 0
|
|
4192
|
+
},
|
|
4193
|
+
theme,
|
|
4194
|
+
dimensions: {
|
|
4195
|
+
width: options.width,
|
|
4196
|
+
height: options.height
|
|
4197
|
+
}
|
|
4198
|
+
};
|
|
4199
|
+
}
|
|
4200
|
+
function compileTable(spec, options) {
|
|
4201
|
+
const { spec: normalized } = compile(spec);
|
|
4202
|
+
if (normalized.type !== "table") {
|
|
4203
|
+
throw new Error(`compileTable received a ${normalized.type} spec. Use compileChart instead.`);
|
|
4204
|
+
}
|
|
4205
|
+
const tableSpec = normalized;
|
|
4206
|
+
const mergedThemeConfig = options.theme ? { ...tableSpec.theme, ...options.theme } : tableSpec.theme;
|
|
4207
|
+
let theme = resolveTheme2(mergedThemeConfig);
|
|
4208
|
+
if (options.darkMode) {
|
|
4209
|
+
theme = adaptTheme2(theme);
|
|
4210
|
+
}
|
|
4211
|
+
return compileTableLayout(tableSpec, options, theme);
|
|
4212
|
+
}
|
|
4213
|
+
function compileGraph2(spec, options) {
|
|
4214
|
+
return compileGraph(spec, options);
|
|
4215
|
+
}
|
|
4216
|
+
export {
|
|
4217
|
+
clearRenderers,
|
|
4218
|
+
compile,
|
|
4219
|
+
compileChart,
|
|
4220
|
+
compileGraph2 as compileGraph,
|
|
4221
|
+
compileTable,
|
|
4222
|
+
getChartRenderer,
|
|
4223
|
+
normalizeSpec,
|
|
4224
|
+
registerChartRenderer,
|
|
4225
|
+
validateSpec
|
|
4226
|
+
};
|
|
4227
|
+
//# sourceMappingURL=index.js.map
|