@internetstiftelsen/charts 0.2.0 → 0.3.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/README.md CHANGED
@@ -4,15 +4,14 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
4
4
 
5
5
  ## Features
6
6
 
7
- - **Framework Agnostic**: Core library has zero framework dependencies - works with vanilla JS, React, Vue, Svelte, or any other framework
8
- - **Flexible Scale System**: Support for band, linear, time, and logarithmic scales
9
- - **Composable Architecture**: Build charts by composing components (lines, axes, grids, tooltips, legends, titles)
10
- - **Layout-Driven Design**: Components self-measure and automatically adjust chart dimensions
11
- - **Automatic Resize**: Built-in ResizeObserver handles responsive behavior automatically
12
- - **Type Safe**: Written in TypeScript with comprehensive type definitions
13
- - **Data Validation**: Built-in validation with helpful error messages
14
- - **Performance Optimized**: Data caching and minimized redundant calculations
15
- - **Automatic Color Assignment**: Smart color palette system with sensible defaults
7
+ - **Framework Agnostic** - Works with vanilla JS, React, Vue, Svelte, or any framework
8
+ - **Composable Architecture** - Build charts by composing components
9
+ - **Multiple Chart Types** - XYChart (lines, bars) and DonutChart
10
+ - **Flexible Scales** - Band, linear, time, and logarithmic scales
11
+ - **Auto Resize** - Built-in ResizeObserver handles responsive behavior
12
+ - **Type Safe** - Written in TypeScript with full type definitions
13
+ - **Data Validation** - Built-in validation with helpful error messages
14
+ - **Auto Colors** - Smart color palette with sensible defaults
16
15
 
17
16
  ## Installation
18
17
 
@@ -22,396 +21,41 @@ npm install @internetstiftelsen/charts
22
21
 
23
22
  ## Quick Start
24
23
 
25
- ### Vanilla JavaScript
26
-
27
24
  ```javascript
28
25
  import { XYChart } from '@internetstiftelsen/charts/xy-chart';
29
26
  import { Line } from '@internetstiftelsen/charts/line';
30
- import { Bar } from '@internetstiftelsen/charts/bar';
31
27
  import { XAxis } from '@internetstiftelsen/charts/x-axis';
32
28
  import { YAxis } from '@internetstiftelsen/charts/y-axis';
33
- import { Grid } from '@internetstiftelsen/charts/grid';
34
- import { Tooltip } from '@internetstiftelsen/charts/tooltip';
35
- import { Legend } from '@internetstiftelsen/charts/legend';
36
29
 
37
- // Your data
38
30
  const data = [
39
- { date: '2010', revenue: 100, expenses: 80 },
40
- { date: '2011', revenue: 150, expenses: 90 },
41
- { date: '2012', revenue: 200, expenses: 110 },
42
- { date: '2013', revenue: 250, expenses: 130 },
31
+ { date: '2023', revenue: 100, expenses: 80 },
32
+ { date: '2024', revenue: 150, expenses: 90 },
43
33
  ];
44
34
 
45
- // Create chart
46
35
  const chart = new XYChart({ data });
47
36
 
48
- // Add components
49
37
  chart
50
- .addChild(new Title({ text: 'Revenue vs Expenses' }))
51
- .addChild(new Grid({ horizontal: true, vertical: false }))
52
38
  .addChild(new XAxis({ dataKey: 'date' }))
53
39
  .addChild(new YAxis())
54
- .addChild(
55
- new Tooltip({
56
- formatter: (dataKey, value) => `<strong>${dataKey}</strong>: $${value}k`,
57
- })
58
- )
59
- .addChild(new Legend({ position: 'bottom' }))
60
- .addChild(new Line({ dataKey: 'revenue' })) // Auto-assigned color
61
- .addChild(new Line({ dataKey: 'expenses' })); // Auto-assigned color
40
+ .addChild(new Line({ dataKey: 'revenue' }))
41
+ .addChild(new Line({ dataKey: 'expenses' }));
62
42
 
63
- // Render to DOM (automatically resizes with container)
64
43
  chart.render('#chart-container');
65
-
66
- // Later: update with new data
67
- chart.update(newData);
68
-
69
- // Clean up when done
70
- chart.destroy();
71
- ```
72
-
73
- ### With React (Demo Wrapper)
74
-
75
- ```jsx
76
- import { useRef, useEffect } from 'react';
77
- import { XYChart, Line, Bar, XAxis, YAxis, Grid, Tooltip, Legend } from './charts';
78
-
79
- function Chart({ data }) {
80
- const containerRef = useRef(null);
81
- const chartRef = useRef(null);
82
-
83
- useEffect(() => {
84
- if (containerRef.current) {
85
- // Create chart
86
- const chart = new XYChart({ data });
87
-
88
- chart
89
- .addChild(new Grid({ horizontal: true }))
90
- .addChild(new XAxis({ dataKey: 'column' }))
91
- .addChild(new YAxis())
92
- .addChild(new Tooltip())
93
- .addChild(new Legend({ position: 'bottom' }))
94
- .addChild(new Line({ dataKey: 'value1' }))
95
- .addChild(new Line({ dataKey: 'value2' }));
96
-
97
- chart.render(containerRef.current);
98
- chartRef.current = chart;
99
-
100
- return () => {
101
- chart.destroy();
102
- };
103
- }
104
- }, []);
105
-
106
- // Update when data changes
107
- useEffect(() => {
108
- if (chartRef.current && data) {
109
- chartRef.current.update(data);
110
- }
111
- }, [data]);
112
-
113
- return <div ref={containerRef} />;
114
- }
115
- ```
116
-
117
- ## API Reference
118
-
119
- ### XYChart
120
-
121
- The main chart class for creating XY-coordinate charts (line, bar, or mixed).
122
-
123
- #### Constructor
124
-
125
- ```typescript
126
- new XYChart(config: XYChartConfig)
127
44
  ```
128
45
 
129
- **Config Options:**
130
-
131
- - `data: DataItem[]` - Array of data objects (required)
132
- - `theme?: Partial<ChartTheme>` - Theme customization
133
- - `width: number` - Chart max-width in pixels (default: 928)
134
- - `height: number` - Chart height in pixels (default: 600)
135
- - `margins: { top, right, bottom, left }` - Base margins around plot area (default: { top: 20, right: 20, bottom: 30, left: 40 })
136
- - `colorPalette: string[]` - Array of colors for auto-assignment
137
- - `gridColor: string` - Grid line color (default: '#e0e0e0')
138
- - `axis: { fontFamily, fontSize }` - Axis text styling
139
- - `scales?: AxisScaleConfig` - Scale configuration
140
- - `x?: { type: 'band' | 'linear' | 'time' | 'log', domain?: any[], padding?: number, nice?: boolean }`
141
- - `y?: { type: 'band' | 'linear' | 'time' | 'log', domain?: any[], padding?: number, nice?: boolean }`
142
-
143
- #### Methods
46
+ ## Documentation
144
47
 
145
- **`addChild(component: ChartComponent): this`**
146
- Adds a component to the chart (chainable).
147
-
148
- **`render(target: string): HTMLElement`**
149
- Renders the chart to a DOM element specified by CSS selector. Automatically sets up resize handling.
150
-
151
- **`update(data: DataItem[]): void`**
152
- Updates the chart with new data and re-renders.
153
-
154
- **`destroy(): void`**
155
- Cleans up all resources, removes resize observer, and clears the chart from the DOM.
156
-
157
- ### Components
158
-
159
- #### Line
160
-
161
- Renders a line series on the chart.
162
-
163
- ```typescript
164
- new Line({
165
- dataKey: string, // Key in data objects for Y values (required)
166
- stroke? : string, // Line color (auto-assigned if omitted)
167
- strokeWidth? : number, // Line width in pixels (default: 2)
168
- })
169
- ```
170
-
171
- #### Bar
172
-
173
- Renders a bar series on the chart.
174
-
175
- ```typescript
176
- new Bar({
177
- dataKey: string, // Key in data objects for Y values (required)
178
- fill? : string, // Bar color (auto-assigned if omitted)
179
- })
180
- ```
181
-
182
- #### XAxis
183
-
184
- Renders the X axis.
185
-
186
- ```typescript
187
- new XAxis({
188
- dataKey? : string, // Key in data objects for X values (auto-detected if omitted)
189
- })
190
- ```
191
-
192
- #### YAxis
193
-
194
- Renders the Y axis.
195
-
196
- ```typescript
197
- new YAxis({
198
- tickFormat?: string | null, // D3 format specifier (e.g., 's' for SI-prefix like "35k"). Default: null (no formatting)
199
- })
200
- ```
201
-
202
- **Examples:**
203
- ```javascript
204
- new YAxis() // Shows raw numbers: 35000
205
- new YAxis({ tickFormat: 's' }) // Shows SI-prefix: 35k
206
- new YAxis({ tickFormat: '$,' }) // Shows formatted: $35,000
207
- ```
208
-
209
- #### Grid
210
-
211
- Renders grid lines.
212
-
213
- ```typescript
214
- new Grid({
215
- horizontal? : boolean, // Show horizontal lines (default: true)
216
- vertical? : boolean, // Show vertical lines (default: true)
217
- })
218
- ```
219
-
220
- #### Tooltip
221
-
222
- Renders interactive tooltips on hover.
223
-
224
- ```typescript
225
- new Tooltip({
226
- formatter? : (dataKey: string, value: any, data: DataItem) => string
227
- })
228
- ```
229
-
230
- **Example formatter:**
231
-
232
- ```javascript
233
- new Tooltip({
234
- formatter: (dataKey, value, data) =>
235
- `<strong>${dataKey}</strong><br/>Value: ${value}<br/>Date: ${data.date}`
236
- })
237
- ```
238
-
239
- #### Legend
240
-
241
- Renders a legend for the chart.
242
-
243
- ```typescript
244
- new Legend({
245
- position?: 'bottom', // Position (currently only 'bottom' supported)
246
- marginTop?: number, // Space above legend (default: 20)
247
- marginBottom?: number, // Space below legend (default: 10)
248
- })
249
- ```
250
-
251
- #### Title
252
-
253
- Renders a title for the chart.
254
-
255
- ```typescript
256
- new Title({
257
- text: string, // Title text (required)
258
- fontSize?: number, // Font size in pixels (default: 18)
259
- fontWeight?: string, // Font weight (default: 'bold')
260
- align?: 'left' | 'center' | 'right', // Alignment (default: 'center')
261
- marginTop?: number, // Space above title (default: 10)
262
- marginBottom?: number, // Space below title (default: 15)
263
- })
264
- ```
265
-
266
- ## Advanced Usage
267
-
268
- ### Custom Scale Types
269
-
270
- Use time scales for temporal data:
271
-
272
- ```javascript
273
- const chart = new XYChart({
274
- data: [
275
- { date: new Date('2024-01-01'), value: 100 },
276
- { date: new Date('2024-01-02'), value: 150 },
277
- ],
278
- scales: {
279
- x: { type: 'time', nice: true },
280
- y: { type: 'linear', nice: true },
281
- },
282
- });
283
- ```
284
-
285
- Use logarithmic scales for exponential data:
286
-
287
- ```javascript
288
- const chart = new XYChart({
289
- data: [
290
- { x: 1, y: 10 },
291
- { x: 2, y: 100 },
292
- { x: 3, y: 1000 },
293
- ],
294
- scales: {
295
- y: { type: 'log', domain: [1, 10000] },
296
- },
297
- });
298
- ```
299
-
300
- ### Custom Theming
301
-
302
- ```javascript
303
- const chart = new XYChart({
304
- data,
305
- theme: {
306
- width: 1200, // Max-width (chart won't exceed this)
307
- height: 600,
308
- margins: {
309
- top: 30,
310
- right: 30,
311
- bottom: 40,
312
- left: 60,
313
- },
314
- colorPalette: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#f7b731'],
315
- gridColor: '#333333',
316
- axis: {
317
- fontFamily: 'Inter, sans-serif',
318
- fontSize: '12',
319
- },
320
- },
321
- });
322
- ```
323
-
324
- ### Manual Color Assignment
325
-
326
- ```javascript
327
- chart
328
- .addChild(new Line({ dataKey: 'revenue', stroke: '#00ff00' }))
329
- .addChild(new Line({ dataKey: 'expenses', stroke: '#ff0000' }));
330
- ```
331
-
332
- ### Responsive Charts
333
-
334
- Charts automatically resize with their container using ResizeObserver. The chart width adapts to the container up to the `theme.width` (which acts as max-width).
335
-
336
- ```javascript
337
- // Container width: 500px → Chart width: 500px
338
- // Container width: 1200px → Chart width: 928px (theme default max-width)
339
-
340
- // Custom max-width
341
- const chart = new XYChart({
342
- data,
343
- theme: { width: 1200 }, // Chart won't exceed 1200px
344
- });
345
- ```
346
-
347
- **No manual resize calls needed** - the chart automatically responds to container size changes!
348
-
349
- ## Data Validation
350
-
351
- The library includes built-in validation with helpful error messages:
352
-
353
- ```javascript
354
- // Empty data
355
- new XYChart({ data: [] });
356
- // Error: Data array cannot be empty
357
-
358
- // Missing dataKey
359
- new Line({ dataKey: 'nonexistent' });
360
- // Error: Line: dataKey "nonexistent" not found in data items at indices: 0, 1, 2
361
-
362
- // Invalid numeric data
363
- new Line({ dataKey: 'textField' });
364
- // Error: Line: No valid numeric values found for dataKey "textField"
365
- ```
48
+ - [Getting Started](./docs/getting-started.md) - Installation, Vanilla JS, React integration
49
+ - [XYChart](./docs/xy-chart.md) - Line and bar charts API
50
+ - [DonutChart](./docs/donut-chart.md) - Donut/pie charts API
51
+ - [Components](./docs/components.md) - Axes, Grid, Tooltip, Legend, Title
52
+ - [Theming](./docs/theming.md) - Colors, fonts, and styling
53
+ - [Advanced](./docs/advanced.md) - Scales, TypeScript, architecture, performance
366
54
 
367
55
  ## Browser Support
368
56
 
369
57
  Modern browsers with ES6+ support. Uses D3.js v7.
370
58
 
371
- ## TypeScript
372
-
373
- Full TypeScript support included:
374
-
375
- ```typescript
376
- import type { DataItem, ChartTheme, LineConfig } from './charts/types';
377
-
378
- const data: DataItem[] = [
379
- { x: 1, y: 100 },
380
- { x: 2, y: 200 },
381
- ];
382
-
383
- const config: LineConfig = {
384
- dataKey: 'y',
385
- stroke: '#8884d8',
386
- strokeWidth: 2,
387
- };
388
- ```
389
-
390
- ## Architecture
391
-
392
- The library follows a composable, layout-driven design:
393
-
394
- - **BaseChart**: Abstract base class providing common functionality (lifecycle, rendering, validation)
395
- - **XYChart**: Concrete implementation for XY-coordinate charts (lines, bars, or mixed)
396
- - **LayoutManager**: Calculates component positions and plot area dimensions (D3 margin convention)
397
- - **LayoutAwareComponent**: Interface for self-measuring components (Title, Legend, Axes)
398
- - **Components**: Modular components that implement `ChartComponent` or `LayoutAwareComponent`
399
- - **Validation**: Centralized validation layer with `ChartValidator`
400
- - **Scales**: Flexible scale factory supporting multiple D3 scale types
401
-
402
- Key principles:
403
- - **Layout-driven**: Components report their space requirements, plot area adjusts automatically
404
- - **Separation of concerns**: Only the plot area (grid) scales; UI elements stay fixed size
405
- - **D3 conventions**: Follows D3's margin convention pattern for clean, predictable layouts
406
-
407
- This architecture makes it easy to add new chart types or series (Area, Scatter, etc.) by extending BaseChart or implementing new series components.
408
-
409
- ## Performance
59
+ ## License
410
60
 
411
- - **Data Caching**: Sorted data is cached to avoid redundant sorting operations
412
- - **Smart Re-rendering**: Only re-renders when necessary (data updates or container resize)
413
- - **Automatic Cleanup**: ResizeObserver and tooltips properly cleaned up on destroy
414
- - **Minimal DOM Manipulation**: Uses D3's efficient data binding
415
- - **SVG Optimization**: Clean SVG generation with proper cleanup
416
- - **Small Bundle**: ~105 KB gzipped (including D3)
417
- - **Small Bundle**: ~105 KB gzipped (including D3)
61
+ MIT
package/base-chart.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type Selection } from 'd3';
2
2
  import type { DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale } from './types.js';
3
- import type { ChartComponent } from './chart-interface.js';
3
+ import type { ChartComponent, LayoutAwareComponent } from './chart-interface.js';
4
4
  import type { XAxis } from './x-axis.js';
5
5
  import type { YAxis } from './y-axis.js';
6
6
  import type { Grid } from './grid.js';
@@ -50,8 +50,9 @@ export declare abstract class BaseChart {
50
50
  private performRender;
51
51
  /**
52
52
  * Get layout-aware components in order
53
+ * Override in subclasses to provide chart-specific components
53
54
  */
54
- private getLayoutComponents;
55
+ protected getLayoutComponents(): LayoutAwareComponent[];
55
56
  /**
56
57
  * Setup ResizeObserver for automatic resize handling
57
58
  */
package/base-chart.js CHANGED
@@ -167,6 +167,7 @@ export class BaseChart {
167
167
  }
168
168
  /**
169
169
  * Get layout-aware components in order
170
+ * Override in subclasses to provide chart-specific components
170
171
  */
171
172
  getLayoutComponents() {
172
173
  const components = [];
@@ -1,5 +1,5 @@
1
1
  export interface ChartComponent {
2
- type: 'line' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'title';
2
+ type: 'line' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'title' | 'donutCenterContent';
3
3
  }
4
4
  export type ComponentSpace = {
5
5
  width: number;
@@ -0,0 +1,27 @@
1
+ import { type Selection } from 'd3';
2
+ import type { ChartTheme } from './types.js';
3
+ import type { ChartComponent } from './chart-interface.js';
4
+ type TextStyle = {
5
+ fontSize?: number;
6
+ fontWeight?: string;
7
+ fontFamily?: string;
8
+ color?: string;
9
+ };
10
+ export type DonutCenterContentConfig = {
11
+ mainValue?: string;
12
+ title?: string;
13
+ subtitle?: string;
14
+ mainValueStyle?: TextStyle;
15
+ titleStyle?: TextStyle;
16
+ subtitleStyle?: TextStyle;
17
+ };
18
+ export declare class DonutCenterContent implements ChartComponent {
19
+ readonly type: "donutCenterContent";
20
+ readonly mainValue?: string;
21
+ readonly title?: string;
22
+ readonly subtitle?: string;
23
+ private readonly config;
24
+ constructor(config?: DonutCenterContentConfig);
25
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, cx: number, cy: number, theme: ChartTheme): void;
26
+ }
27
+ export {};
@@ -0,0 +1,98 @@
1
+ export class DonutCenterContent {
2
+ constructor(config = {}) {
3
+ Object.defineProperty(this, "type", {
4
+ enumerable: true,
5
+ configurable: true,
6
+ writable: true,
7
+ value: 'donutCenterContent'
8
+ });
9
+ Object.defineProperty(this, "mainValue", {
10
+ enumerable: true,
11
+ configurable: true,
12
+ writable: true,
13
+ value: void 0
14
+ });
15
+ Object.defineProperty(this, "title", {
16
+ enumerable: true,
17
+ configurable: true,
18
+ writable: true,
19
+ value: void 0
20
+ });
21
+ Object.defineProperty(this, "subtitle", {
22
+ enumerable: true,
23
+ configurable: true,
24
+ writable: true,
25
+ value: void 0
26
+ });
27
+ Object.defineProperty(this, "config", {
28
+ enumerable: true,
29
+ configurable: true,
30
+ writable: true,
31
+ value: void 0
32
+ });
33
+ this.mainValue = config.mainValue;
34
+ this.title = config.title;
35
+ this.subtitle = config.subtitle;
36
+ this.config = config;
37
+ }
38
+ render(svg, cx, cy, theme) {
39
+ const defaults = theme.donut.centerContent;
40
+ const elements = [];
41
+ if (this.mainValue) {
42
+ const style = this.config.mainValueStyle;
43
+ elements.push({
44
+ text: this.mainValue,
45
+ fontSize: style?.fontSize ?? defaults.mainValue.fontSize,
46
+ fontWeight: style?.fontWeight ?? defaults.mainValue.fontWeight,
47
+ fontFamily: style?.fontFamily ??
48
+ defaults.mainValue.fontFamily ??
49
+ theme.fontFamily,
50
+ color: style?.color ?? defaults.mainValue.color,
51
+ });
52
+ }
53
+ if (this.title) {
54
+ const style = this.config.titleStyle;
55
+ elements.push({
56
+ text: this.title,
57
+ fontSize: style?.fontSize ?? defaults.title.fontSize,
58
+ fontWeight: style?.fontWeight ?? defaults.title.fontWeight,
59
+ fontFamily: style?.fontFamily ??
60
+ defaults.title.fontFamily ??
61
+ theme.fontFamily,
62
+ color: style?.color ?? defaults.title.color,
63
+ });
64
+ }
65
+ if (this.subtitle) {
66
+ const style = this.config.subtitleStyle;
67
+ elements.push({
68
+ text: this.subtitle,
69
+ fontSize: style?.fontSize ?? defaults.subtitle.fontSize,
70
+ fontWeight: style?.fontWeight ?? defaults.subtitle.fontWeight,
71
+ fontFamily: style?.fontFamily ??
72
+ defaults.subtitle.fontFamily ??
73
+ theme.fontFamily,
74
+ color: style?.color ?? defaults.subtitle.color,
75
+ });
76
+ }
77
+ if (elements.length === 0)
78
+ return;
79
+ const lineSpacing = 6;
80
+ const totalHeight = elements.reduce((sum, el, i) => sum + el.fontSize + (i < elements.length - 1 ? lineSpacing : 0), 0);
81
+ const group = svg.append('g').attr('class', 'donut-center-content');
82
+ let currentY = cy - totalHeight / 2;
83
+ for (const el of elements) {
84
+ group
85
+ .append('text')
86
+ .attr('x', cx)
87
+ .attr('y', currentY + el.fontSize / 2)
88
+ .attr('text-anchor', 'middle')
89
+ .attr('dominant-baseline', 'middle')
90
+ .style('font-size', `${el.fontSize}px`)
91
+ .style('font-weight', el.fontWeight)
92
+ .style('font-family', el.fontFamily)
93
+ .style('fill', el.color)
94
+ .text(el.text);
95
+ currentY += el.fontSize + lineSpacing;
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,32 @@
1
+ import type { DataItem } from './types.js';
2
+ import { BaseChart, type BaseChartConfig } from './base-chart.js';
3
+ import type { ChartComponent, LayoutAwareComponent } from './chart-interface.js';
4
+ export type DonutConfig = {
5
+ innerRadius?: number;
6
+ padAngle?: number;
7
+ cornerRadius?: number;
8
+ };
9
+ export type DonutChartConfig = BaseChartConfig & {
10
+ donut?: DonutConfig;
11
+ valueKey?: string;
12
+ labelKey?: string;
13
+ };
14
+ export declare class DonutChart extends BaseChart {
15
+ private readonly innerRadiusRatio;
16
+ private readonly padAngle;
17
+ private readonly cornerRadius;
18
+ private readonly valueKey;
19
+ private readonly labelKey;
20
+ private segments;
21
+ private centerContent;
22
+ constructor(config: DonutChartConfig);
23
+ private validateDonutData;
24
+ private prepareSegments;
25
+ addChild(component: ChartComponent): this;
26
+ update(data: DataItem[]): void;
27
+ protected getLayoutComponents(): LayoutAwareComponent[];
28
+ protected renderChart(): void;
29
+ private positionTooltip;
30
+ private buildTooltipContent;
31
+ private renderSegments;
32
+ }
package/donut-chart.js ADDED
@@ -0,0 +1,240 @@
1
+ import { arc, pie, select } from 'd3';
2
+ import { BaseChart } from './base-chart.js';
3
+ import { sanitizeForCSS } from './utils.js';
4
+ import { ChartValidator } from './validation.js';
5
+ const HOVER_EXPAND_PX = 8;
6
+ const ANIMATION_DURATION_MS = 150;
7
+ const TOOLTIP_OFFSET_PX = 12;
8
+ const EDGE_MARGIN_PX = 10;
9
+ export class DonutChart extends BaseChart {
10
+ constructor(config) {
11
+ super(config);
12
+ Object.defineProperty(this, "innerRadiusRatio", {
13
+ enumerable: true,
14
+ configurable: true,
15
+ writable: true,
16
+ value: void 0
17
+ });
18
+ Object.defineProperty(this, "padAngle", {
19
+ enumerable: true,
20
+ configurable: true,
21
+ writable: true,
22
+ value: void 0
23
+ });
24
+ Object.defineProperty(this, "cornerRadius", {
25
+ enumerable: true,
26
+ configurable: true,
27
+ writable: true,
28
+ value: void 0
29
+ });
30
+ Object.defineProperty(this, "valueKey", {
31
+ enumerable: true,
32
+ configurable: true,
33
+ writable: true,
34
+ value: void 0
35
+ });
36
+ Object.defineProperty(this, "labelKey", {
37
+ enumerable: true,
38
+ configurable: true,
39
+ writable: true,
40
+ value: void 0
41
+ });
42
+ Object.defineProperty(this, "segments", {
43
+ enumerable: true,
44
+ configurable: true,
45
+ writable: true,
46
+ value: []
47
+ });
48
+ Object.defineProperty(this, "centerContent", {
49
+ enumerable: true,
50
+ configurable: true,
51
+ writable: true,
52
+ value: null
53
+ });
54
+ const donut = config.donut ?? {};
55
+ this.innerRadiusRatio =
56
+ donut.innerRadius ?? this.theme.donut.innerRadius;
57
+ this.padAngle = donut.padAngle ?? this.theme.donut.padAngle;
58
+ this.cornerRadius = donut.cornerRadius ?? this.theme.donut.cornerRadius;
59
+ this.valueKey = config.valueKey ?? 'value';
60
+ this.labelKey = config.labelKey ?? 'name';
61
+ this.validateDonutData();
62
+ this.prepareSegments();
63
+ }
64
+ validateDonutData() {
65
+ ChartValidator.validateDataKey(this.data, this.labelKey, 'DonutChart');
66
+ ChartValidator.validateDataKey(this.data, this.valueKey, 'DonutChart');
67
+ ChartValidator.validateNumericData(this.data, this.valueKey, 'DonutChart');
68
+ for (const [index, item] of this.data.entries()) {
69
+ const value = this.parseValue(item[this.valueKey]);
70
+ if (value < 0) {
71
+ throw new Error(`DonutChart: data item at index ${index} has negative value '${item[this.valueKey]}' for key '${this.valueKey}'`);
72
+ }
73
+ }
74
+ }
75
+ prepareSegments() {
76
+ this.segments = this.data.map((item, index) => ({
77
+ label: String(item[this.labelKey]),
78
+ value: this.parseValue(item[this.valueKey]),
79
+ color: item.color ||
80
+ this.theme.colorPalette[index % this.theme.colorPalette.length],
81
+ }));
82
+ }
83
+ addChild(component) {
84
+ const type = component.type;
85
+ if (type === 'tooltip') {
86
+ this.tooltip = component;
87
+ }
88
+ else if (type === 'legend') {
89
+ this.legend = component;
90
+ this.legend.setToggleCallback(() => this.update(this.data));
91
+ }
92
+ else if (type === 'title') {
93
+ this.title = component;
94
+ }
95
+ else if (type === 'donutCenterContent') {
96
+ this.centerContent = component;
97
+ }
98
+ return this;
99
+ }
100
+ update(data) {
101
+ this.data = data;
102
+ this.validateDonutData();
103
+ this.prepareSegments();
104
+ super.update(data);
105
+ }
106
+ getLayoutComponents() {
107
+ const components = [];
108
+ if (this.title)
109
+ components.push(this.title);
110
+ if (this.legend)
111
+ components.push(this.legend);
112
+ return components;
113
+ }
114
+ renderChart() {
115
+ if (!this.plotArea || !this.svg || !this.plotGroup) {
116
+ throw new Error('Plot area not calculated');
117
+ }
118
+ if (this.title) {
119
+ const pos = this.layoutManager.getComponentPosition(this.title);
120
+ this.title.render(this.svg, this.theme, this.width, pos.x, pos.y);
121
+ }
122
+ const visibleSegments = this.legend
123
+ ? this.segments.filter((seg) => this.legend.isSeriesVisible(seg.label))
124
+ : this.segments;
125
+ const cx = this.plotArea.left + this.plotArea.width / 2;
126
+ const cy = this.plotArea.top + this.plotArea.height / 2;
127
+ const outerRadius = Math.min(this.plotArea.width, this.plotArea.height) / 2;
128
+ const innerRadius = outerRadius * this.innerRadiusRatio;
129
+ this.renderSegments(visibleSegments, cx, cy, innerRadius, outerRadius);
130
+ if (this.centerContent) {
131
+ this.centerContent.render(this.svg, cx, cy, this.theme);
132
+ }
133
+ if (this.legend) {
134
+ const pos = this.layoutManager.getComponentPosition(this.legend);
135
+ const legendSeries = this.segments.map((seg) => ({
136
+ dataKey: seg.label,
137
+ fill: seg.color,
138
+ }));
139
+ this.legend.render(this.svg, legendSeries, this.theme, this.width, pos.x, pos.y);
140
+ }
141
+ if (this.tooltip) {
142
+ this.tooltip.initialize(this.theme);
143
+ }
144
+ }
145
+ positionTooltip(event, tooltipDiv) {
146
+ const node = tooltipDiv.node();
147
+ if (!node)
148
+ return;
149
+ const rect = node.getBoundingClientRect();
150
+ let x = event.pageX + TOOLTIP_OFFSET_PX;
151
+ let y = event.pageY - rect.height / 2;
152
+ if (x + rect.width > window.innerWidth - EDGE_MARGIN_PX) {
153
+ x = event.pageX - rect.width - TOOLTIP_OFFSET_PX;
154
+ }
155
+ x = Math.max(EDGE_MARGIN_PX, x);
156
+ y = Math.max(EDGE_MARGIN_PX, Math.min(y, window.innerHeight +
157
+ window.scrollY -
158
+ rect.height -
159
+ EDGE_MARGIN_PX));
160
+ tooltipDiv.style('left', `${x}px`).style('top', `${y}px`);
161
+ }
162
+ buildTooltipContent(d, segments) {
163
+ const total = segments.reduce((sum, s) => sum + s.value, 0);
164
+ const percentage = total > 0 ? ((d.data.value / total) * 100).toFixed(1) : '0.0';
165
+ const dataItem = this.data.find((item) => String(item[this.labelKey]) === d.data.label);
166
+ if (this.tooltip?.customFormatter) {
167
+ return this.tooltip.customFormatter(dataItem || {}, [
168
+ { dataKey: d.data.label, fill: d.data.color },
169
+ ]);
170
+ }
171
+ if (this.tooltip?.formatter) {
172
+ return `<strong>${d.data.label}</strong><br/>${this.tooltip.formatter(d.data.label, d.data.value, dataItem || {})}`;
173
+ }
174
+ return `<strong>${d.data.label}</strong><br/>${d.data.value} (${percentage}%)`;
175
+ }
176
+ renderSegments(segments, cx, cy, innerRadius, outerRadius) {
177
+ if (!this.plotGroup || !this.svg)
178
+ return;
179
+ const pieGenerator = pie()
180
+ .value((d) => d.value)
181
+ .padAngle(this.padAngle)
182
+ .sort(null);
183
+ const arcGenerator = arc()
184
+ .innerRadius(innerRadius)
185
+ .outerRadius(outerRadius)
186
+ .cornerRadius(this.cornerRadius);
187
+ const hoverArcGenerator = arc()
188
+ .innerRadius(innerRadius)
189
+ .outerRadius(outerRadius + HOVER_EXPAND_PX)
190
+ .cornerRadius(this.cornerRadius);
191
+ const pieData = pieGenerator(segments);
192
+ const segmentGroup = this.plotGroup
193
+ .append('g')
194
+ .attr('class', 'donut-segments')
195
+ .attr('transform', `translate(${cx}, ${cy})`);
196
+ const tooltipDiv = this.tooltip
197
+ ? select(`#${this.tooltip.id}`)
198
+ : null;
199
+ segmentGroup
200
+ .selectAll('.donut-segment')
201
+ .data(pieData)
202
+ .join('path')
203
+ .attr('class', (d) => `donut-segment segment-${sanitizeForCSS(d.data.label)}`)
204
+ .attr('d', arcGenerator)
205
+ .attr('fill', (d) => d.data.color)
206
+ .style('cursor', 'pointer')
207
+ .style('transition', 'opacity 0.15s ease')
208
+ .on('mouseenter', (event, d) => {
209
+ select(event.currentTarget)
210
+ .transition()
211
+ .duration(ANIMATION_DURATION_MS)
212
+ .attr('d', hoverArcGenerator(d));
213
+ segmentGroup
214
+ .selectAll('.donut-segment')
215
+ .filter((_, i, nodes) => nodes[i] !== event.currentTarget)
216
+ .style('opacity', 0.5);
217
+ if (tooltipDiv && !tooltipDiv.empty()) {
218
+ tooltipDiv
219
+ .style('visibility', 'visible')
220
+ .html(this.buildTooltipContent(d, segments));
221
+ this.positionTooltip(event, tooltipDiv);
222
+ }
223
+ })
224
+ .on('mousemove', (event) => {
225
+ if (tooltipDiv && !tooltipDiv.empty()) {
226
+ this.positionTooltip(event, tooltipDiv);
227
+ }
228
+ })
229
+ .on('mouseleave', (event, d) => {
230
+ select(event.currentTarget)
231
+ .transition()
232
+ .duration(ANIMATION_DURATION_MS)
233
+ .attr('d', arcGenerator(d));
234
+ segmentGroup.selectAll('.donut-segment').style('opacity', 1);
235
+ if (tooltipDiv && !tooltipDiv.empty()) {
236
+ tooltipDiv.style('visibility', 'hidden');
237
+ }
238
+ });
239
+ }
240
+ }
package/legend.d.ts CHANGED
@@ -1,7 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { LegendConfig, ChartTheme } from './types.js';
3
- import type { Line } from './line.js';
4
- import type { Bar } from './bar.js';
2
+ import type { LegendConfig, ChartTheme, LegendSeries } from './types.js';
5
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
6
4
  export declare class Legend implements LayoutAwareComponent {
7
5
  readonly type: "legend";
@@ -20,5 +18,5 @@ export declare class Legend implements LayoutAwareComponent {
20
18
  * Returns the space required by the legend
21
19
  */
22
20
  getRequiredSpace(): ComponentSpace;
23
- render(svg: Selection<SVGSVGElement, undefined, null, undefined>, series: (Line | Bar)[], theme: ChartTheme, width: number, _x?: number, y?: number): void;
21
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, series: LegendSeries[], theme: ChartTheme, width: number, _x?: number, y?: number): void;
24
22
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.2.0",
2
+ "version": "0.3.1",
3
3
  "name": "@internetstiftelsen/charts",
4
4
  "type": "module",
5
5
  "sideEffects": false,
@@ -31,38 +31,38 @@
31
31
  "@radix-ui/react-select": "^2.2.6",
32
32
  "@radix-ui/react-switch": "^1.2.6",
33
33
  "@radix-ui/react-tabs": "^1.1.13",
34
- "@tailwindcss/vite": "^4.1.16",
35
34
  "@types/d3": "^7.4.3",
36
35
  "class-variance-authority": "^0.7.1",
37
36
  "clsx": "^2.1.1",
38
37
  "d3": "^7.9.0",
39
38
  "handsontable": "^16.2.0",
40
39
  "lucide-react": "^0.548.0",
41
- "react": "^19.2.0",
42
- "react-dom": "^19.2.0",
43
- "tailwind-merge": "^3.3.1",
44
- "tailwindcss": "^4.1.16"
40
+ "react": "^19.2.4",
41
+ "react-dom": "^19.2.4",
42
+ "tailwind-merge": "^3.4.0",
43
+ "tailwindcss": "^4.1.18"
45
44
  },
46
45
  "devDependencies": {
47
- "@eslint/js": "^9.38.0",
46
+ "@eslint/js": "^9.39.2",
47
+ "@tailwindcss/vite": "^4.1.18",
48
48
  "@testing-library/dom": "^10.4.1",
49
49
  "@testing-library/jest-dom": "^6.9.1",
50
50
  "@testing-library/react": "^16.3.2",
51
- "@types/node": "^24.9.2",
52
- "@types/react": "^19.2.2",
53
- "@types/react-dom": "^19.2.2",
54
- "@vitejs/plugin-react-swc": "^4.2.0",
55
- "eslint": "^9.38.0",
51
+ "@types/node": "^24.10.9",
52
+ "@types/react": "^19.2.10",
53
+ "@types/react-dom": "^19.2.3",
54
+ "@vitejs/plugin-react-swc": "^4.2.2",
55
+ "eslint": "^9.39.2",
56
56
  "eslint-plugin-react-hooks": "^7.0.1",
57
- "eslint-plugin-react-refresh": "^0.4.24",
58
- "globals": "^16.4.0",
57
+ "eslint-plugin-react-refresh": "^0.4.26",
58
+ "globals": "^16.5.0",
59
59
  "jsdom": "^27.4.0",
60
60
  "prettier": "3.6.2",
61
61
  "tsc-alias": "^1.8.16",
62
62
  "tw-animate-css": "^1.4.0",
63
63
  "typescript": "~5.9.3",
64
- "typescript-eslint": "^8.46.2",
65
- "vite": "^7.1.12",
66
- "vitest": "^4.0.17"
64
+ "typescript-eslint": "^8.54.0",
65
+ "vite": "^7.3.1",
66
+ "vitest": "^4.0.18"
67
67
  }
68
68
  }
package/theme.js CHANGED
@@ -50,6 +50,28 @@ export const defaultTheme = {
50
50
  borderRadius: 4,
51
51
  padding: 4,
52
52
  },
53
+ donut: {
54
+ innerRadius: 0.5,
55
+ padAngle: 0.02,
56
+ cornerRadius: 0,
57
+ centerContent: {
58
+ mainValue: {
59
+ fontSize: 32,
60
+ fontWeight: 'bold',
61
+ color: '#1f2a36',
62
+ },
63
+ title: {
64
+ fontSize: 14,
65
+ fontWeight: 'normal',
66
+ color: '#6b7280',
67
+ },
68
+ subtitle: {
69
+ fontSize: 12,
70
+ fontWeight: 'normal',
71
+ color: '#9ca3af',
72
+ },
73
+ },
74
+ },
53
75
  };
54
76
  export const newspaperTheme = {
55
77
  width: 928,
@@ -104,6 +126,28 @@ export const newspaperTheme = {
104
126
  borderRadius: 2,
105
127
  padding: 3,
106
128
  },
129
+ donut: {
130
+ innerRadius: 0.5,
131
+ padAngle: 0.015,
132
+ cornerRadius: 0,
133
+ centerContent: {
134
+ mainValue: {
135
+ fontSize: 28,
136
+ fontWeight: 'bold',
137
+ color: '#1a1a1a',
138
+ },
139
+ title: {
140
+ fontSize: 13,
141
+ fontWeight: 'normal',
142
+ color: '#4a4a4a',
143
+ },
144
+ subtitle: {
145
+ fontSize: 11,
146
+ fontWeight: 'normal',
147
+ color: '#6b6b6b',
148
+ },
149
+ },
150
+ },
107
151
  };
108
152
  export const themes = {
109
153
  default: defaultTheme,
package/types.d.ts CHANGED
@@ -50,6 +50,31 @@ export type ChartTheme = {
50
50
  borderRadius: number;
51
51
  padding: number;
52
52
  };
53
+ donut: {
54
+ innerRadius: number;
55
+ padAngle: number;
56
+ cornerRadius: number;
57
+ centerContent: {
58
+ mainValue: {
59
+ fontSize: number;
60
+ fontWeight: string;
61
+ fontFamily?: string;
62
+ color: string;
63
+ };
64
+ title: {
65
+ fontSize: number;
66
+ fontWeight: string;
67
+ fontFamily?: string;
68
+ color: string;
69
+ };
70
+ subtitle: {
71
+ fontSize: number;
72
+ fontWeight: string;
73
+ fontFamily?: string;
74
+ color: string;
75
+ };
76
+ };
77
+ };
53
78
  };
54
79
  export type ValueLabelConfig = {
55
80
  fontSize?: number;
@@ -126,6 +151,11 @@ export type LegendConfig = {
126
151
  marginTop?: number;
127
152
  marginBottom?: number;
128
153
  };
154
+ export type LegendSeries = {
155
+ dataKey: string;
156
+ stroke?: string;
157
+ fill?: string;
158
+ };
129
159
  export type TitleConfig = {
130
160
  text: string;
131
161
  fontSize?: number;