@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/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
+ }