@tinta-lang/charts 0.0.0-stage → 0.1.16
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/LICENSE +21 -0
- package/README.md +12 -2
- package/THIRD_PARTY_NOTICES.txt +13651 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +271 -0
- package/package.json +45 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PreparedChart } from "@tinta-lang/types";
|
|
2
|
+
export interface SceneNode {
|
|
3
|
+
readonly tag: "path" | "line" | "rect" | "circle" | "text";
|
|
4
|
+
readonly attributes: Readonly<Record<string, string | number>>;
|
|
5
|
+
readonly text?: string;
|
|
6
|
+
readonly pointId?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface ChartScene {
|
|
9
|
+
readonly width: number;
|
|
10
|
+
readonly height: number;
|
|
11
|
+
readonly nodes: readonly SceneNode[];
|
|
12
|
+
readonly message: string;
|
|
13
|
+
readonly total: number;
|
|
14
|
+
}
|
|
15
|
+
export declare const palette: readonly ["#0072B2", "#D55E00", "#009E73", "#CC79A7", "#8C6D00", "#6F4E7C"];
|
|
16
|
+
export declare function coordinate(value: number): string;
|
|
17
|
+
export declare function tickLabel(value: string | number): string;
|
|
18
|
+
/** Produces only renderer-owned primitives; accepts no author SVG or style. */
|
|
19
|
+
export declare function chartScene(chart: PreparedChart, contentWidth: number, hidden?: ReadonlySet<string>, selected?: string | number): ChartScene;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { chartPresentation } from "@tinta-lang/engine/chart-presentation";
|
|
2
|
+
import { ticks } from "d3-array";
|
|
3
|
+
import { scaleBand, scaleLinear, scalePoint } from "d3-scale";
|
|
4
|
+
import { arc, area, curveLinear, line, pie, symbol, symbolCircle, symbolSquare, symbolTriangle, symbolDiamond, symbolCross, symbolStar, } from "d3-shape";
|
|
5
|
+
export const palette = [
|
|
6
|
+
"#0072B2",
|
|
7
|
+
"#D55E00",
|
|
8
|
+
"#009E73",
|
|
9
|
+
"#CC79A7",
|
|
10
|
+
"#8C6D00",
|
|
11
|
+
"#6F4E7C",
|
|
12
|
+
];
|
|
13
|
+
const dashes = ["none", "6 3", "2 3", "8 3 2 3", "1 2", "10 3"];
|
|
14
|
+
const shapes = [
|
|
15
|
+
symbolCircle,
|
|
16
|
+
symbolSquare,
|
|
17
|
+
symbolTriangle,
|
|
18
|
+
symbolDiamond,
|
|
19
|
+
symbolCross,
|
|
20
|
+
symbolStar,
|
|
21
|
+
];
|
|
22
|
+
export function coordinate(value) {
|
|
23
|
+
if (!Number.isFinite(value))
|
|
24
|
+
throw new Error("E_CHART_DOMAIN");
|
|
25
|
+
return String(Number(value.toFixed(3)));
|
|
26
|
+
}
|
|
27
|
+
export function tickLabel(value) {
|
|
28
|
+
const characters = [...String(value)];
|
|
29
|
+
return characters.length > 14
|
|
30
|
+
? characters.slice(0, 14).join("") + "…"
|
|
31
|
+
: characters.join("");
|
|
32
|
+
}
|
|
33
|
+
/** Produces only renderer-owned primitives; accepts no author SVG or style. */
|
|
34
|
+
export function chartScene(chart, contentWidth, hidden = new Set(), selected) {
|
|
35
|
+
if (!Number.isFinite(contentWidth))
|
|
36
|
+
throw new Error("E_CHART_DOMAIN");
|
|
37
|
+
const width = Math.max(280, Math.min(800, Math.round(contentWidth)));
|
|
38
|
+
const height = Math.round(Math.max(240, Math.min(400, width * 0.55)));
|
|
39
|
+
const view = chartPresentation(chart, hidden);
|
|
40
|
+
const nodes = [];
|
|
41
|
+
const add = (tag, attributes, text, pointId) => {
|
|
42
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
43
|
+
if (typeof value === "number")
|
|
44
|
+
attributes[key] = coordinate(value);
|
|
45
|
+
else if ((key === "d" || key === "transform") &&
|
|
46
|
+
/NaN|Infinity/.test(value))
|
|
47
|
+
throw new Error("E_CHART_DOMAIN");
|
|
48
|
+
}
|
|
49
|
+
nodes.push({
|
|
50
|
+
tag,
|
|
51
|
+
attributes,
|
|
52
|
+
...(text === undefined ? {} : { text }),
|
|
53
|
+
...(pointId ? { pointId } : {}),
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const finish = () => ({
|
|
57
|
+
width,
|
|
58
|
+
height,
|
|
59
|
+
nodes,
|
|
60
|
+
message: view.message,
|
|
61
|
+
total: view.total,
|
|
62
|
+
});
|
|
63
|
+
const color = (index) => palette[index % palette.length];
|
|
64
|
+
const sector = chart.type === "pie" || chart.type === "donut";
|
|
65
|
+
if (sector) {
|
|
66
|
+
if (view.total > 0) {
|
|
67
|
+
const radius = Math.min(width, height) / 2 - 20;
|
|
68
|
+
// Normalizing before D3 avoids overflow in its angle multiplier for tiny totals.
|
|
69
|
+
const slices = pie()
|
|
70
|
+
.sort(null)
|
|
71
|
+
.padAngle(0)
|
|
72
|
+
.value((point) => point.y / view.total)(view.points);
|
|
73
|
+
const path = arc()
|
|
74
|
+
.innerRadius(chart.type === "donut" ? radius * 0.6 : 0)
|
|
75
|
+
.outerRadius(radius)
|
|
76
|
+
.digits(3);
|
|
77
|
+
for (const slice of slices) {
|
|
78
|
+
if (slice.data.y === 0)
|
|
79
|
+
continue;
|
|
80
|
+
const index = chart.points.findIndex((point) => point.id === slice.data.id);
|
|
81
|
+
add("path", {
|
|
82
|
+
d: path(slice) ?? "",
|
|
83
|
+
transform: `translate(${coordinate(width / 2)},${coordinate(height / 2)})`,
|
|
84
|
+
fill: color(index),
|
|
85
|
+
stroke: selected === slice.data.x ? "#1f2328" : "#ffffff",
|
|
86
|
+
"stroke-width": selected === slice.data.x ? 3 : 1,
|
|
87
|
+
}, undefined, slice.data.id);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (chart.type === "donut") {
|
|
91
|
+
add("text", {
|
|
92
|
+
x: width / 2,
|
|
93
|
+
y: height / 2,
|
|
94
|
+
"text-anchor": "middle",
|
|
95
|
+
fill: "#1f2328",
|
|
96
|
+
}, `Visible total: ${view.total}`);
|
|
97
|
+
}
|
|
98
|
+
return finish();
|
|
99
|
+
}
|
|
100
|
+
if (!view.yDomain)
|
|
101
|
+
return finish();
|
|
102
|
+
const left = 64, right = width - 20, top = 20, bottom = height - 48;
|
|
103
|
+
const y = scaleLinear().domain(view.yDomain).range([bottom, top]);
|
|
104
|
+
const categories = [...new Set(chart.points.map((point) => String(point.x)))];
|
|
105
|
+
const band = scaleBand()
|
|
106
|
+
.domain(categories)
|
|
107
|
+
.range([left, right])
|
|
108
|
+
.paddingInner(0.2)
|
|
109
|
+
.paddingOuter(0.1);
|
|
110
|
+
const pointScale = scalePoint()
|
|
111
|
+
.domain(categories)
|
|
112
|
+
.range([left, right])
|
|
113
|
+
.padding(0.5);
|
|
114
|
+
const numeric = view.xDomain
|
|
115
|
+
? scaleLinear().domain(view.xDomain).range([left, right])
|
|
116
|
+
: undefined;
|
|
117
|
+
const x = (point) => chart.type === "bar"
|
|
118
|
+
? band(String(point.x))
|
|
119
|
+
: numeric
|
|
120
|
+
? numeric(point.x)
|
|
121
|
+
: pointScale(String(point.x));
|
|
122
|
+
add("line", {
|
|
123
|
+
x1: left,
|
|
124
|
+
x2: left,
|
|
125
|
+
y1: top,
|
|
126
|
+
y2: bottom,
|
|
127
|
+
stroke: "#59636e",
|
|
128
|
+
});
|
|
129
|
+
add("line", {
|
|
130
|
+
x1: left,
|
|
131
|
+
x2: right,
|
|
132
|
+
y1: bottom,
|
|
133
|
+
y2: bottom,
|
|
134
|
+
stroke: "#59636e",
|
|
135
|
+
});
|
|
136
|
+
for (const value of ticks(view.yDomain[0], view.yDomain[1], 5)) {
|
|
137
|
+
add("text", {
|
|
138
|
+
x: left - 8,
|
|
139
|
+
y: y(value),
|
|
140
|
+
"text-anchor": "end",
|
|
141
|
+
fill: "#1f2328",
|
|
142
|
+
}, tickLabel(value));
|
|
143
|
+
}
|
|
144
|
+
if (numeric && chart.type !== "bar") {
|
|
145
|
+
for (const value of ticks(view.xDomain[0], view.xDomain[1], 5)) {
|
|
146
|
+
add("text", {
|
|
147
|
+
x: numeric(value),
|
|
148
|
+
y: bottom + 20,
|
|
149
|
+
"text-anchor": "middle",
|
|
150
|
+
fill: "#1f2328",
|
|
151
|
+
}, tickLabel(value));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
const stride = Math.ceil(categories.length / Math.max(2, Math.floor((right - left) / 70)));
|
|
156
|
+
categories.forEach((value, i) => {
|
|
157
|
+
if (i % stride === 0 || i === categories.length - 1) {
|
|
158
|
+
add("text", {
|
|
159
|
+
x: chart.type === "bar"
|
|
160
|
+
? band(value) + band.bandwidth() / 2
|
|
161
|
+
: pointScale(value),
|
|
162
|
+
y: bottom + 20,
|
|
163
|
+
"text-anchor": "middle",
|
|
164
|
+
fill: "#1f2328",
|
|
165
|
+
}, tickLabel(value));
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
add("text", {
|
|
170
|
+
x: (left + right) / 2,
|
|
171
|
+
y: height - 4,
|
|
172
|
+
"text-anchor": "middle",
|
|
173
|
+
fill: "#1f2328",
|
|
174
|
+
}, chart.xLabel);
|
|
175
|
+
add("text", { x: left, y: 12, fill: "#1f2328" }, chart.yLabel);
|
|
176
|
+
if (chart.type === "histogram") {
|
|
177
|
+
for (const bin of chart.bins) {
|
|
178
|
+
add("rect", {
|
|
179
|
+
x: numeric(bin.lower),
|
|
180
|
+
y: y(bin.count),
|
|
181
|
+
width: Math.max(1, numeric(bin.upper) - numeric(bin.lower)),
|
|
182
|
+
height: Math.max(1, y(0) - y(bin.count)),
|
|
183
|
+
fill: color(0),
|
|
184
|
+
stroke: "#1f2328",
|
|
185
|
+
"stroke-width": 0.5,
|
|
186
|
+
}, undefined, bin.id);
|
|
187
|
+
}
|
|
188
|
+
return finish();
|
|
189
|
+
}
|
|
190
|
+
const visibleSeries = chart.series
|
|
191
|
+
.map((series, index) => ({ ...series, index }))
|
|
192
|
+
.filter((series) => !hidden.has(series.id));
|
|
193
|
+
const sub = scaleBand()
|
|
194
|
+
.domain(visibleSeries.map((series) => series.index))
|
|
195
|
+
.range([0, band.bandwidth()])
|
|
196
|
+
.paddingInner(0.1);
|
|
197
|
+
for (const series of visibleSeries) {
|
|
198
|
+
const points = view.points.filter((point) => point.seriesIndex === series.index);
|
|
199
|
+
if (chart.type === "line" || chart.type === "area") {
|
|
200
|
+
if (chart.type === "area") {
|
|
201
|
+
const shape = area()
|
|
202
|
+
.x(x)
|
|
203
|
+
.y0(y(0))
|
|
204
|
+
.y1((point) => y(point.y))
|
|
205
|
+
.curve(curveLinear)
|
|
206
|
+
.digits(3);
|
|
207
|
+
add("path", {
|
|
208
|
+
d: shape(points) ?? "",
|
|
209
|
+
fill: color(series.index),
|
|
210
|
+
"fill-opacity": 0.15,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const shape = line()
|
|
214
|
+
.x(x)
|
|
215
|
+
.y((point) => y(point.y))
|
|
216
|
+
.curve(curveLinear)
|
|
217
|
+
.digits(3);
|
|
218
|
+
add("path", {
|
|
219
|
+
d: shape(points) ?? "",
|
|
220
|
+
fill: "none",
|
|
221
|
+
stroke: color(series.index),
|
|
222
|
+
"stroke-width": 2,
|
|
223
|
+
"stroke-dasharray": dashes[series.index],
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const point of view.points) {
|
|
228
|
+
const selectedPoint = selected === point.x;
|
|
229
|
+
const common = {
|
|
230
|
+
fill: color(point.seriesIndex),
|
|
231
|
+
stroke: "#1f2328",
|
|
232
|
+
"stroke-width": selectedPoint ? 3 : 0.5,
|
|
233
|
+
};
|
|
234
|
+
if (chart.type === "bar") {
|
|
235
|
+
add("rect", {
|
|
236
|
+
...common,
|
|
237
|
+
x: x(point) + sub(point.seriesIndex),
|
|
238
|
+
y: Math.min(y(0), y(point.y)),
|
|
239
|
+
width: Math.max(1, sub.bandwidth()),
|
|
240
|
+
height: Math.max(1, Math.abs(y(point.y) - y(0))),
|
|
241
|
+
}, undefined, point.id);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
if (chart.type === "scatter") {
|
|
245
|
+
const path = symbol()
|
|
246
|
+
.type(shapes[point.seriesIndex])
|
|
247
|
+
.size(Math.PI * 16)
|
|
248
|
+
.digits(3)();
|
|
249
|
+
add("path", {
|
|
250
|
+
...common,
|
|
251
|
+
d: path ?? "",
|
|
252
|
+
transform: `translate(${coordinate(x(point))},${coordinate(y(point.y))})`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else
|
|
256
|
+
add("circle", {
|
|
257
|
+
...common,
|
|
258
|
+
cx: x(point),
|
|
259
|
+
cy: y(point.y),
|
|
260
|
+
r: 4,
|
|
261
|
+
});
|
|
262
|
+
add("circle", {
|
|
263
|
+
cx: x(point),
|
|
264
|
+
cy: y(point.y),
|
|
265
|
+
r: 12,
|
|
266
|
+
fill: "transparent",
|
|
267
|
+
}, undefined, point.id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return finish();
|
|
271
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tinta-lang/charts",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.16",
|
|
4
|
+
"description": "Create SVG-ready scenes for interactive tinta document charts.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tinta",
|
|
7
|
+
"charts",
|
|
8
|
+
"svg",
|
|
9
|
+
"documents"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/planta7/tinta/blob/main/packages/charts/README.md",
|
|
12
|
+
"private": false,
|
|
13
|
+
"type": "module",
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=24.21.0 <25"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public",
|
|
20
|
+
"tag": "latest"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@tinta-lang/types": "0.1.16",
|
|
30
|
+
"@tinta-lang/engine": "0.1.16",
|
|
31
|
+
"d3-array": "3.2.4",
|
|
32
|
+
"d3-scale": "4.0.2",
|
|
33
|
+
"d3-shape": "3.2.0"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"LICENSE",
|
|
37
|
+
"README.md",
|
|
38
|
+
"THIRD_PARTY_NOTICES.txt",
|
|
39
|
+
"dist/index.d.ts",
|
|
40
|
+
"dist/index.js"
|
|
41
|
+
],
|
|
42
|
+
"types": "./dist/index.d.ts",
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "https://github.com/planta7/tinta.git",
|
|
46
|
+
"directory": "packages/charts"
|
|
47
|
+
}
|
|
6
48
|
}
|