@internetstiftelsen/charts 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bar.d.ts +13 -0
- package/bar.js +137 -0
- package/base-chart.d.ts +72 -0
- package/base-chart.js +227 -0
- package/chart-interface.d.ts +11 -0
- package/chart-interface.js +1 -0
- package/grid.d.ts +10 -0
- package/grid.js +63 -0
- package/layout-manager.d.ts +38 -0
- package/layout-manager.js +123 -0
- package/legend.d.ts +23 -0
- package/legend.js +157 -0
- package/line.d.ts +11 -0
- package/line.js +82 -0
- package/package.json +57 -0
- package/theme.d.ts +2 -0
- package/theme.js +31 -0
- package/title.d.ts +18 -0
- package/title.js +88 -0
- package/tooltip.d.ts +15 -0
- package/tooltip.js +138 -0
- package/types.d.ts +83 -0
- package/types.js +4 -0
- package/utils.d.ts +2 -0
- package/utils.js +5 -0
- package/validation.d.ts +29 -0
- package/validation.js +75 -0
- package/x-axis.d.ts +16 -0
- package/x-axis.js +70 -0
- package/xy-chart.d.ts +18 -0
- package/xy-chart.js +272 -0
- package/y-axis.d.ts +15 -0
- package/y-axis.js +58 -0
package/xy-chart.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { max, min, scaleBand, scaleLinear, scaleTime, scaleLog, } from 'd3';
|
|
2
|
+
import { getSeriesColor } from './types';
|
|
3
|
+
import { BaseChart } from './base-chart';
|
|
4
|
+
import { ChartValidator } from './validation';
|
|
5
|
+
export class XYChart extends BaseChart {
|
|
6
|
+
constructor(config) {
|
|
7
|
+
super(config);
|
|
8
|
+
Object.defineProperty(this, "series", {
|
|
9
|
+
enumerable: true,
|
|
10
|
+
configurable: true,
|
|
11
|
+
writable: true,
|
|
12
|
+
value: []
|
|
13
|
+
});
|
|
14
|
+
Object.defineProperty(this, "sortedDataCache", {
|
|
15
|
+
enumerable: true,
|
|
16
|
+
configurable: true,
|
|
17
|
+
writable: true,
|
|
18
|
+
value: null
|
|
19
|
+
});
|
|
20
|
+
Object.defineProperty(this, "xKeyCache", {
|
|
21
|
+
enumerable: true,
|
|
22
|
+
configurable: true,
|
|
23
|
+
writable: true,
|
|
24
|
+
value: null
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
addChild(component) {
|
|
28
|
+
const type = component.type;
|
|
29
|
+
if (type === 'line' || type === 'bar') {
|
|
30
|
+
const series = component;
|
|
31
|
+
// Auto-assign color if not specified
|
|
32
|
+
const defaultColor = '#8884d8';
|
|
33
|
+
const currentColor = getSeriesColor(series);
|
|
34
|
+
if (!currentColor || currentColor === defaultColor) {
|
|
35
|
+
const colorIndex = this.series.length % this.theme.colorPalette.length;
|
|
36
|
+
const newColor = this.theme.colorPalette[colorIndex];
|
|
37
|
+
if (type === 'line') {
|
|
38
|
+
series.stroke = newColor;
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
series.fill = newColor;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
this.series.push(series);
|
|
45
|
+
}
|
|
46
|
+
else if (type === 'xAxis') {
|
|
47
|
+
this.xAxis = component;
|
|
48
|
+
}
|
|
49
|
+
else if (type === 'yAxis') {
|
|
50
|
+
this.yAxis = component;
|
|
51
|
+
}
|
|
52
|
+
else if (type === 'grid') {
|
|
53
|
+
this.grid = component;
|
|
54
|
+
}
|
|
55
|
+
else if (type === 'tooltip') {
|
|
56
|
+
this.tooltip = component;
|
|
57
|
+
}
|
|
58
|
+
else if (type === 'legend') {
|
|
59
|
+
this.legend = component;
|
|
60
|
+
this.legend.setToggleCallback(() => {
|
|
61
|
+
this.rerender();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
else if (type === 'title') {
|
|
65
|
+
this.title = component;
|
|
66
|
+
}
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
rerender() {
|
|
70
|
+
this.update(this.data);
|
|
71
|
+
}
|
|
72
|
+
renderChart() {
|
|
73
|
+
if (!this.plotArea) {
|
|
74
|
+
throw new Error('Plot area not calculated');
|
|
75
|
+
}
|
|
76
|
+
// Validate that all series dataKeys exist in data
|
|
77
|
+
this.series.forEach((series) => {
|
|
78
|
+
const typeName = series.type === 'line' ? 'Line' : 'Bar';
|
|
79
|
+
ChartValidator.validateDataKey(this.data, series.dataKey, typeName);
|
|
80
|
+
ChartValidator.validateNumericData(this.data, series.dataKey, typeName);
|
|
81
|
+
});
|
|
82
|
+
// Validate xAxis dataKey if specified
|
|
83
|
+
if (this.xAxis?.dataKey) {
|
|
84
|
+
ChartValidator.validateDataKey(this.data, this.xAxis.dataKey, 'XAxis');
|
|
85
|
+
}
|
|
86
|
+
// Cache sorted data
|
|
87
|
+
const xKey = this.getXKey();
|
|
88
|
+
const sortedData = this.getSortedData(xKey);
|
|
89
|
+
this.setupScales();
|
|
90
|
+
// Render title if present
|
|
91
|
+
if (this.title) {
|
|
92
|
+
const titlePos = this.layoutManager.getComponentPosition(this.title);
|
|
93
|
+
this.title.render(this.svg, this.theme, this.width, titlePos.x, titlePos.y);
|
|
94
|
+
}
|
|
95
|
+
// Render grid in the plot group
|
|
96
|
+
if (this.grid && this.x && this.y) {
|
|
97
|
+
this.grid.render(this.plotGroup, this.x, this.y, this.theme);
|
|
98
|
+
}
|
|
99
|
+
// Render series in the plot group
|
|
100
|
+
this.renderSeries();
|
|
101
|
+
// Render axes at plot area boundaries
|
|
102
|
+
if (this.x && this.y) {
|
|
103
|
+
if (this.xAxis) {
|
|
104
|
+
this.xAxis.render(this.svg, this.x, this.theme, this.plotArea.bottom);
|
|
105
|
+
}
|
|
106
|
+
if (this.yAxis) {
|
|
107
|
+
this.yAxis.render(this.svg, this.y, this.theme, this.plotArea.left);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// Render tooltip
|
|
111
|
+
if (this.tooltip && this.x && this.y) {
|
|
112
|
+
this.tooltip.initialize(this.theme);
|
|
113
|
+
this.tooltip.attachToArea(this.svg, sortedData, this.series, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this));
|
|
114
|
+
}
|
|
115
|
+
// Render legend if present
|
|
116
|
+
if (this.legend) {
|
|
117
|
+
const legendPos = this.layoutManager.getComponentPosition(this.legend);
|
|
118
|
+
this.legend.render(this.svg, this.series, this.theme, this.width, legendPos.x, legendPos.y);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
getXKey() {
|
|
122
|
+
if (this.xAxis?.dataKey) {
|
|
123
|
+
return this.xAxis.dataKey;
|
|
124
|
+
}
|
|
125
|
+
return (Object.keys(this.data[0]).find((key) => !this.series.some((s) => s.dataKey === key)) || 'column');
|
|
126
|
+
}
|
|
127
|
+
getSortedData(xKey) {
|
|
128
|
+
// Return cached data if xKey matches
|
|
129
|
+
if (this.sortedDataCache && this.xKeyCache === xKey) {
|
|
130
|
+
return this.sortedDataCache;
|
|
131
|
+
}
|
|
132
|
+
// Sort and cache
|
|
133
|
+
this.xKeyCache = xKey;
|
|
134
|
+
this.sortedDataCache = [...this.data].sort((a, b) => String(a[xKey]).localeCompare(String(b[xKey])));
|
|
135
|
+
return this.sortedDataCache;
|
|
136
|
+
}
|
|
137
|
+
setupScales() {
|
|
138
|
+
const xKey = this.getXKey();
|
|
139
|
+
const isHorizontal = this.isHorizontalOrientation();
|
|
140
|
+
let xConfig;
|
|
141
|
+
let yConfig;
|
|
142
|
+
if (isHorizontal) {
|
|
143
|
+
// For horizontal bars, swap the user's scale configuration
|
|
144
|
+
// User's X scale config → Y scale (categories)
|
|
145
|
+
// User's Y scale config → X scale (values)
|
|
146
|
+
xConfig = this.scaleConfig.y || { type: 'linear' };
|
|
147
|
+
yConfig = this.scaleConfig.x || { type: 'band' };
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
// Vertical orientation uses normal configuration
|
|
151
|
+
xConfig = this.scaleConfig.x || { type: 'band' };
|
|
152
|
+
yConfig = this.scaleConfig.y || { type: 'linear' };
|
|
153
|
+
}
|
|
154
|
+
// Setup X scale
|
|
155
|
+
this.x = this.createScale(xConfig, isHorizontal ? null : xKey, 'x');
|
|
156
|
+
// Setup Y scale
|
|
157
|
+
this.y = this.createScale(yConfig, isHorizontal ? xKey : null, 'y');
|
|
158
|
+
}
|
|
159
|
+
isHorizontalOrientation() {
|
|
160
|
+
return this.series.some((s) => s.type === 'bar' && s.orientation === 'horizontal');
|
|
161
|
+
}
|
|
162
|
+
createScale(config, dataKey, axis) {
|
|
163
|
+
if (!this.plotArea) {
|
|
164
|
+
throw new Error('Plot area not calculated');
|
|
165
|
+
}
|
|
166
|
+
const scaleType = config.type || (axis === 'x' ? 'band' : 'linear');
|
|
167
|
+
const isXAxis = axis === 'x';
|
|
168
|
+
// Add padding between plot area and axis
|
|
169
|
+
const plotPadding = 10;
|
|
170
|
+
// For X-axis: add padding on the left (start)
|
|
171
|
+
// For Y-axis: add padding on the bottom (start, which is the larger value since Y goes bottom-to-top)
|
|
172
|
+
const rangeStart = isXAxis
|
|
173
|
+
? this.plotArea.left + plotPadding
|
|
174
|
+
: this.plotArea.bottom - plotPadding;
|
|
175
|
+
const rangeEnd = isXAxis ? this.plotArea.right : this.plotArea.top;
|
|
176
|
+
let domain;
|
|
177
|
+
if (config.domain) {
|
|
178
|
+
domain = config.domain;
|
|
179
|
+
}
|
|
180
|
+
else if (scaleType === 'band' && dataKey) {
|
|
181
|
+
domain = this.data.map((d) => String(d[dataKey]));
|
|
182
|
+
}
|
|
183
|
+
else if (scaleType === 'time' && dataKey) {
|
|
184
|
+
domain = [
|
|
185
|
+
min(this.data, (d) => new Date(d[dataKey])),
|
|
186
|
+
max(this.data, (d) => new Date(d[dataKey])),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
else if ((scaleType === 'linear' || scaleType === 'log') && dataKey) {
|
|
190
|
+
// For linear and log scales with a dataKey, calculate from that key
|
|
191
|
+
const values = this.data.map((d) => this.parseValue(d[dataKey]));
|
|
192
|
+
const minVal = min(values) ?? 0;
|
|
193
|
+
const maxVal = max(values) ?? 100;
|
|
194
|
+
domain =
|
|
195
|
+
scaleType === 'log' && minVal <= 0
|
|
196
|
+
? [1, maxVal]
|
|
197
|
+
: [minVal, maxVal];
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
// Calculate from series data (for value axes without explicit dataKey)
|
|
201
|
+
const values = this.data.flatMap((d) => this.series.map((s) => this.parseValue(d[s.dataKey])));
|
|
202
|
+
const minVal = min(values) ?? 0;
|
|
203
|
+
const maxVal = max(values) ?? 100;
|
|
204
|
+
domain =
|
|
205
|
+
scaleType === 'log' && minVal <= 0
|
|
206
|
+
? [1, maxVal]
|
|
207
|
+
: [minVal, maxVal];
|
|
208
|
+
}
|
|
209
|
+
switch (scaleType) {
|
|
210
|
+
case 'band': {
|
|
211
|
+
const scale = scaleBand()
|
|
212
|
+
.domain(domain)
|
|
213
|
+
.rangeRound([rangeStart, rangeEnd]);
|
|
214
|
+
if (config.padding !== undefined) {
|
|
215
|
+
scale.paddingInner(config.padding);
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
scale.paddingInner(0.1);
|
|
219
|
+
}
|
|
220
|
+
return scale;
|
|
221
|
+
}
|
|
222
|
+
case 'linear': {
|
|
223
|
+
const scale = scaleLinear()
|
|
224
|
+
.domain(domain)
|
|
225
|
+
.rangeRound([rangeStart, rangeEnd]);
|
|
226
|
+
if (config.nice !== false) {
|
|
227
|
+
scale.nice();
|
|
228
|
+
}
|
|
229
|
+
return scale;
|
|
230
|
+
}
|
|
231
|
+
case 'time': {
|
|
232
|
+
const scale = scaleTime()
|
|
233
|
+
.domain(domain)
|
|
234
|
+
.range([rangeStart, rangeEnd]);
|
|
235
|
+
if (config.nice !== false) {
|
|
236
|
+
scale.nice();
|
|
237
|
+
}
|
|
238
|
+
return scale;
|
|
239
|
+
}
|
|
240
|
+
case 'log': {
|
|
241
|
+
const scale = scaleLog()
|
|
242
|
+
.domain(domain)
|
|
243
|
+
.rangeRound([rangeStart, rangeEnd]);
|
|
244
|
+
if (config.nice !== false) {
|
|
245
|
+
scale.nice();
|
|
246
|
+
}
|
|
247
|
+
return scale;
|
|
248
|
+
}
|
|
249
|
+
default:
|
|
250
|
+
throw new Error(`Unsupported scale type: ${scaleType}`);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
renderSeries() {
|
|
254
|
+
if (!this.plotGroup || !this.x || !this.y)
|
|
255
|
+
return;
|
|
256
|
+
const xKey = this.getXKey();
|
|
257
|
+
const sortedData = this.getSortedData(xKey);
|
|
258
|
+
const isHorizontal = this.isHorizontalOrientation();
|
|
259
|
+
// For horizontal bars, the category scale is on Y (user's X config becomes Y)
|
|
260
|
+
// For vertical bars, the category scale is on X (user's X config stays X)
|
|
261
|
+
const categoryScaleType = isHorizontal
|
|
262
|
+
? this.scaleConfig.x?.type || 'band'
|
|
263
|
+
: this.scaleConfig.x?.type || 'band';
|
|
264
|
+
// Filter series based on legend visibility
|
|
265
|
+
const visibleSeries = this.legend
|
|
266
|
+
? this.series.filter((series) => this.legend.isSeriesVisible(series.dataKey))
|
|
267
|
+
: this.series;
|
|
268
|
+
visibleSeries.forEach((series) => {
|
|
269
|
+
series.render(this.plotGroup, sortedData, xKey, this.x, this.y, this.parseValue, categoryScaleType);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
package/y-axis.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Selection } from 'd3';
|
|
2
|
+
import type { ChartTheme, YAxisConfig } from './types';
|
|
3
|
+
import type { LayoutAwareComponent, ComponentSpace } from '@/lib/chart-interface';
|
|
4
|
+
export declare class YAxis implements LayoutAwareComponent {
|
|
5
|
+
readonly type: "yAxis";
|
|
6
|
+
private readonly tickPadding;
|
|
7
|
+
private readonly maxLabelWidth;
|
|
8
|
+
private readonly tickFormat;
|
|
9
|
+
constructor(config?: YAxisConfig);
|
|
10
|
+
/**
|
|
11
|
+
* Returns the space required by the y-axis
|
|
12
|
+
*/
|
|
13
|
+
getRequiredSpace(): ComponentSpace;
|
|
14
|
+
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, y: any, theme: ChartTheme, xPosition: number): void;
|
|
15
|
+
}
|
package/y-axis.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { axisLeft } from 'd3';
|
|
2
|
+
export class YAxis {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
Object.defineProperty(this, "type", {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true,
|
|
8
|
+
value: 'yAxis'
|
|
9
|
+
});
|
|
10
|
+
Object.defineProperty(this, "tickPadding", {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: 10
|
|
15
|
+
});
|
|
16
|
+
Object.defineProperty(this, "maxLabelWidth", {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: 40
|
|
21
|
+
}); // Estimated max width for Y-axis labels
|
|
22
|
+
Object.defineProperty(this, "tickFormat", {
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
writable: true,
|
|
26
|
+
value: void 0
|
|
27
|
+
});
|
|
28
|
+
this.tickFormat = config?.tickFormat ?? null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the space required by the y-axis
|
|
32
|
+
*/
|
|
33
|
+
getRequiredSpace() {
|
|
34
|
+
// Width = max label width + tick padding
|
|
35
|
+
return {
|
|
36
|
+
width: this.maxLabelWidth + this.tickPadding,
|
|
37
|
+
height: 0, // Y-axis spans full height
|
|
38
|
+
position: 'left',
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
render(svg, y, theme, xPosition) {
|
|
42
|
+
const axis = axisLeft(y).tickSize(0).tickPadding(this.tickPadding);
|
|
43
|
+
// Apply tick formatting if specified
|
|
44
|
+
if (this.tickFormat) {
|
|
45
|
+
axis.ticks(5, this.tickFormat);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
axis.ticks(5);
|
|
49
|
+
}
|
|
50
|
+
svg.append('g')
|
|
51
|
+
.attr('transform', `translate(${xPosition},0)`)
|
|
52
|
+
.call(axis)
|
|
53
|
+
.attr('font-size', theme.axis.fontSize)
|
|
54
|
+
.attr('font-family', theme.axis.fontFamily)
|
|
55
|
+
.selectAll('.domain')
|
|
56
|
+
.remove();
|
|
57
|
+
}
|
|
58
|
+
}
|