@internetstiftelsen/charts 0.5.0 → 0.6.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/README.md CHANGED
@@ -6,7 +6,7 @@ A framework-agnostic, composable charting library built on D3.js with TypeScript
6
6
 
7
7
  - **Framework Agnostic** - Works with vanilla JS, React, Vue, Svelte, or any framework
8
8
  - **Composable Architecture** - Build charts by composing components
9
- - **Multiple Chart Types** - XYChart (lines, bars) and DonutChart
9
+ - **Multiple Chart Types** - XYChart (lines, areas, bars), DonutChart, PieChart, and GaugeChart
10
10
  - **Flexible Scales** - Band, linear, time, and logarithmic scales
11
11
  - **Auto Resize** - Built-in ResizeObserver handles responsive behavior
12
12
  - **Type Safe** - Written in TypeScript with full type definitions
@@ -60,8 +60,10 @@ await chart.export('pdf', { download: true, pdfMargin: 16 });
60
60
  ## Documentation
61
61
 
62
62
  - [Getting Started](./docs/getting-started.md) - Installation, Vanilla JS, React integration
63
- - [XYChart](./docs/xy-chart.md) - Line and bar charts API
63
+ - [XYChart](./docs/xy-chart.md) - Line, area, and bar charts API
64
64
  - [DonutChart](./docs/donut-chart.md) - Donut/pie charts API
65
+ - [PieChart](./docs/pie-chart.md) - Pie chart API
66
+ - [GaugeChart](./docs/gauge-chart.md) - Gauge chart API
65
67
  - [Components](./docs/components.md) - Axes, Grid, Tooltip, Legend, Title
66
68
  - [Theming](./docs/theming.md) - Colors, fonts, and styling
67
69
  - [Advanced](./docs/advanced.md) - Scales, TypeScript, architecture, performance
package/area.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { type Selection } from 'd3';
2
+ import type { AreaConfig, AreaCurveType, AreaConfigBase, AreaStackingContext, ChartTheme, D3Scale, DataItem, ExportHooks, LineValueLabelConfig, ScaleType } from './types.js';
3
+ import type { ChartComponent } from './chart-interface.js';
4
+ export declare class Area implements ChartComponent<AreaConfigBase> {
5
+ readonly type: "area";
6
+ readonly dataKey: string;
7
+ readonly fill: string;
8
+ readonly stroke: string;
9
+ readonly strokeWidth?: number;
10
+ readonly opacity: number;
11
+ readonly curve: AreaCurveType;
12
+ readonly stackId?: string | number;
13
+ readonly baseline: number;
14
+ readonly showLine: boolean;
15
+ readonly showPoints: boolean;
16
+ readonly pointSize?: number;
17
+ readonly valueLabel?: LineValueLabelConfig;
18
+ readonly exportHooks?: ExportHooks<AreaConfigBase>;
19
+ constructor(config: AreaConfig);
20
+ getExportConfig(): AreaConfigBase;
21
+ createExportComponent(override?: Partial<AreaConfigBase>): ChartComponent;
22
+ private getScaledPosition;
23
+ private getStackValues;
24
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme, stackingContext?: AreaStackingContext, valueLabelLayer?: Selection<SVGGElement, undefined, null, undefined>): void;
25
+ private renderValueLabels;
26
+ }
package/area.js ADDED
@@ -0,0 +1,331 @@
1
+ import { area, curveBasis, curveCardinal, curveLinear, curveMonotoneX, curveNatural, curveStep, line, } from 'd3';
2
+ import { mergeDeep, sanitizeForCSS } from './utils.js';
3
+ const AREA_CURVE_FACTORIES = {
4
+ linear: curveLinear,
5
+ monotone: curveMonotoneX,
6
+ step: curveStep,
7
+ natural: curveNatural,
8
+ basis: curveBasis,
9
+ cardinal: curveCardinal,
10
+ };
11
+ export class Area {
12
+ constructor(config) {
13
+ Object.defineProperty(this, "type", {
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true,
17
+ value: 'area'
18
+ });
19
+ Object.defineProperty(this, "dataKey", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: void 0
24
+ });
25
+ Object.defineProperty(this, "fill", {
26
+ enumerable: true,
27
+ configurable: true,
28
+ writable: true,
29
+ value: void 0
30
+ });
31
+ Object.defineProperty(this, "stroke", {
32
+ enumerable: true,
33
+ configurable: true,
34
+ writable: true,
35
+ value: void 0
36
+ });
37
+ Object.defineProperty(this, "strokeWidth", {
38
+ enumerable: true,
39
+ configurable: true,
40
+ writable: true,
41
+ value: void 0
42
+ });
43
+ Object.defineProperty(this, "opacity", {
44
+ enumerable: true,
45
+ configurable: true,
46
+ writable: true,
47
+ value: void 0
48
+ });
49
+ Object.defineProperty(this, "curve", {
50
+ enumerable: true,
51
+ configurable: true,
52
+ writable: true,
53
+ value: void 0
54
+ });
55
+ Object.defineProperty(this, "stackId", {
56
+ enumerable: true,
57
+ configurable: true,
58
+ writable: true,
59
+ value: void 0
60
+ });
61
+ Object.defineProperty(this, "baseline", {
62
+ enumerable: true,
63
+ configurable: true,
64
+ writable: true,
65
+ value: void 0
66
+ });
67
+ Object.defineProperty(this, "showLine", {
68
+ enumerable: true,
69
+ configurable: true,
70
+ writable: true,
71
+ value: void 0
72
+ });
73
+ Object.defineProperty(this, "showPoints", {
74
+ enumerable: true,
75
+ configurable: true,
76
+ writable: true,
77
+ value: void 0
78
+ });
79
+ Object.defineProperty(this, "pointSize", {
80
+ enumerable: true,
81
+ configurable: true,
82
+ writable: true,
83
+ value: void 0
84
+ });
85
+ Object.defineProperty(this, "valueLabel", {
86
+ enumerable: true,
87
+ configurable: true,
88
+ writable: true,
89
+ value: void 0
90
+ });
91
+ Object.defineProperty(this, "exportHooks", {
92
+ enumerable: true,
93
+ configurable: true,
94
+ writable: true,
95
+ value: void 0
96
+ });
97
+ const fill = config.fill || '#8884d8';
98
+ this.dataKey = config.dataKey;
99
+ this.fill = fill;
100
+ this.stroke = config.stroke || fill;
101
+ this.strokeWidth = config.strokeWidth;
102
+ this.opacity = config.opacity ?? 0.3;
103
+ this.curve = config.curve || 'linear';
104
+ this.stackId = config.stackId;
105
+ this.baseline = config.baseline ?? 0;
106
+ this.showLine = config.showLine ?? true;
107
+ this.showPoints = config.showPoints ?? false;
108
+ this.pointSize = config.pointSize;
109
+ this.valueLabel = config.valueLabel;
110
+ this.exportHooks = config.exportHooks;
111
+ }
112
+ getExportConfig() {
113
+ return {
114
+ dataKey: this.dataKey,
115
+ fill: this.fill,
116
+ stroke: this.stroke,
117
+ strokeWidth: this.strokeWidth,
118
+ opacity: this.opacity,
119
+ curve: this.curve,
120
+ stackId: this.stackId,
121
+ baseline: this.baseline,
122
+ showLine: this.showLine,
123
+ showPoints: this.showPoints,
124
+ pointSize: this.pointSize,
125
+ valueLabel: this.valueLabel,
126
+ };
127
+ }
128
+ createExportComponent(override) {
129
+ const merged = mergeDeep(this.getExportConfig(), override);
130
+ return new Area({
131
+ ...merged,
132
+ exportHooks: this.exportHooks,
133
+ });
134
+ }
135
+ getScaledPosition(data, key, scale, scaleType) {
136
+ const value = data[key];
137
+ let scaledValue;
138
+ switch (scaleType) {
139
+ case 'band':
140
+ scaledValue = String(value);
141
+ break;
142
+ case 'time':
143
+ scaledValue =
144
+ value instanceof Date ? value : new Date(String(value));
145
+ break;
146
+ case 'linear':
147
+ case 'log':
148
+ scaledValue = typeof value === 'number' ? value : Number(value);
149
+ break;
150
+ }
151
+ return scale(scaledValue) || 0;
152
+ }
153
+ getStackValues(dataPoint, xKey, parseValue, stackingContext) {
154
+ const value = parseValue(dataPoint[this.dataKey]);
155
+ if (!stackingContext || stackingContext.mode === 'none') {
156
+ return {
157
+ y0: this.baseline,
158
+ y1: value,
159
+ };
160
+ }
161
+ const categoryKey = String(dataPoint[xKey]);
162
+ const cumulative = stackingContext.cumulativeData.get(categoryKey) ?? 0;
163
+ if (stackingContext.mode === 'percent') {
164
+ const total = stackingContext.totalData.get(categoryKey) ?? 0;
165
+ if (total === 0) {
166
+ return { y0: 0, y1: 0 };
167
+ }
168
+ const y0 = (cumulative / total) * 100;
169
+ const y1 = ((cumulative + value) / total) * 100;
170
+ return { y0, y1 };
171
+ }
172
+ return {
173
+ y0: cumulative,
174
+ y1: cumulative + value,
175
+ };
176
+ }
177
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, valueLabelLayer) {
178
+ const getXPosition = (d) => {
179
+ const scaled = this.getScaledPosition(d.data, xKey, x, xScaleType);
180
+ return scaled + (x.bandwidth ? x.bandwidth() / 2 : 0);
181
+ };
182
+ const hasValidValue = (d) => {
183
+ const value = d[this.dataKey];
184
+ if (value === null || value === undefined) {
185
+ return false;
186
+ }
187
+ return Number.isFinite(parseValue(value));
188
+ };
189
+ const areaData = data.map((d) => {
190
+ const valid = hasValidValue(d);
191
+ const stackValues = valid
192
+ ? this.getStackValues(d, xKey, parseValue, stackingContext)
193
+ : { y0: this.baseline, y1: this.baseline };
194
+ return {
195
+ data: d,
196
+ valid,
197
+ y0: stackValues.y0,
198
+ y1: stackValues.y1,
199
+ };
200
+ });
201
+ const curveFactory = AREA_CURVE_FACTORIES[this.curve] || curveLinear;
202
+ const areaGenerator = area()
203
+ .defined((d) => d.valid)
204
+ .curve(curveFactory)
205
+ .x(getXPosition)
206
+ .y0((d) => y(d.y0) || 0)
207
+ .y1((d) => y(d.y1) || 0);
208
+ const areaOpacity = Math.max(0, Math.min(1, this.opacity));
209
+ const sanitizedKey = sanitizeForCSS(this.dataKey);
210
+ plotGroup
211
+ .append('path')
212
+ .datum(areaData)
213
+ .attr('class', `area-${sanitizedKey}`)
214
+ .attr('fill', this.fill)
215
+ .attr('fill-opacity', areaOpacity)
216
+ .attr('stroke', 'none')
217
+ .attr('d', areaGenerator);
218
+ if (this.showLine) {
219
+ const lineGenerator = line()
220
+ .defined((d) => d.valid)
221
+ .curve(curveFactory)
222
+ .x(getXPosition)
223
+ .y((d) => y(d.y1) || 0);
224
+ const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
225
+ plotGroup
226
+ .append('path')
227
+ .datum(areaData)
228
+ .attr('class', `area-line-${sanitizedKey}`)
229
+ .attr('fill', 'none')
230
+ .attr('stroke', this.stroke)
231
+ .attr('stroke-width', lineStrokeWidth)
232
+ .attr('d', lineGenerator);
233
+ }
234
+ if (this.showPoints) {
235
+ const validData = areaData.filter((d) => d.valid);
236
+ const pointSize = this.pointSize ?? theme.line.point.size;
237
+ const pointStrokeWidth = theme.line.point.strokeWidth;
238
+ const pointStrokeColor = theme.line.point.strokeColor || this.stroke;
239
+ const pointColor = theme.line.point.color || this.stroke;
240
+ plotGroup
241
+ .selectAll(`.area-point-${sanitizedKey}`)
242
+ .data(validData)
243
+ .join('circle')
244
+ .attr('class', `area-point-${sanitizedKey}`)
245
+ .attr('cx', getXPosition)
246
+ .attr('cy', (d) => y(d.y1) || 0)
247
+ .attr('r', pointSize)
248
+ .attr('fill', pointColor)
249
+ .attr('stroke', pointStrokeColor)
250
+ .attr('stroke-width', pointStrokeWidth);
251
+ }
252
+ if (this.valueLabel?.show) {
253
+ this.renderValueLabels(valueLabelLayer ?? plotGroup, areaData.filter((d) => d.valid), y, parseValue, theme, getXPosition);
254
+ }
255
+ }
256
+ renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
257
+ const config = this.valueLabel;
258
+ const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
259
+ const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
260
+ const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
261
+ const color = config.color ?? theme.valueLabel.color;
262
+ const background = config.background ?? theme.valueLabel.background ?? '#ffffff';
263
+ const border = config.border ?? theme.valueLabel.border;
264
+ const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
265
+ const padding = config.padding ?? theme.valueLabel.padding;
266
+ const labelGroup = plotGroup
267
+ .append('g')
268
+ .attr('class', `area-value-labels-${sanitizeForCSS(this.dataKey)}`);
269
+ const plotTop = y.range()[1];
270
+ const plotBottom = y.range()[0];
271
+ data.forEach((d) => {
272
+ const rawValue = d.data[this.dataKey];
273
+ if (rawValue === null || rawValue === undefined) {
274
+ return;
275
+ }
276
+ const parsedValue = parseValue(rawValue);
277
+ if (!Number.isFinite(parsedValue)) {
278
+ return;
279
+ }
280
+ const valueText = String(parsedValue);
281
+ const xPos = getXPosition(d);
282
+ const yPos = y(d.y1) || 0;
283
+ const tempText = labelGroup
284
+ .append('text')
285
+ .style('font-size', `${fontSize}px`)
286
+ .style('font-family', fontFamily)
287
+ .style('font-weight', fontWeight)
288
+ .text(valueText);
289
+ const textBBox = tempText.node().getBBox();
290
+ const boxWidth = textBBox.width + padding * 2;
291
+ const boxHeight = textBBox.height + padding * 2;
292
+ const labelX = xPos;
293
+ let labelY = yPos - boxHeight / 2 - theme.line.point.size - 4;
294
+ let shouldRender = true;
295
+ if (labelY - boxHeight / 2 < plotTop + 4) {
296
+ labelY = yPos + boxHeight / 2 + theme.line.point.size + 4;
297
+ if (labelY + boxHeight / 2 > plotBottom - 4) {
298
+ shouldRender = false;
299
+ }
300
+ }
301
+ tempText.remove();
302
+ if (!shouldRender) {
303
+ return;
304
+ }
305
+ const group = labelGroup.append('g');
306
+ group
307
+ .append('rect')
308
+ .attr('x', labelX - boxWidth / 2)
309
+ .attr('y', labelY - boxHeight / 2)
310
+ .attr('width', boxWidth)
311
+ .attr('height', boxHeight)
312
+ .attr('rx', borderRadius)
313
+ .attr('ry', borderRadius)
314
+ .attr('fill', background)
315
+ .attr('stroke', border)
316
+ .attr('stroke-width', 1);
317
+ group
318
+ .append('text')
319
+ .attr('x', labelX)
320
+ .attr('y', labelY)
321
+ .attr('text-anchor', 'middle')
322
+ .attr('dominant-baseline', 'central')
323
+ .style('font-size', `${fontSize}px`)
324
+ .style('font-family', fontFamily)
325
+ .style('font-weight', fontWeight)
326
+ .style('fill', color)
327
+ .style('pointer-events', 'none')
328
+ .text(valueText);
329
+ });
330
+ }
331
+ }
package/base-chart.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type Selection } from 'd3';
2
- import type { DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext } from './types.js';
2
+ import type { ChartData, DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale, ExportHookContext, ExportRenderContext } from './types.js';
3
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';
@@ -9,8 +9,13 @@ import type { Legend } from './legend.js';
9
9
  import type { Title } from './title.js';
10
10
  import { LayoutManager, type PlotAreaBounds } from './layout-manager.js';
11
11
  type VisualExportFormat = 'svg' | 'png' | 'jpg' | 'pdf';
12
+ type RenderDimensions = {
13
+ width: number;
14
+ height: number;
15
+ svgHeightAttr: number | string;
16
+ };
12
17
  export type BaseChartConfig = {
13
- data: DataItem[];
18
+ data: ChartData;
14
19
  theme?: Partial<ChartTheme>;
15
20
  scales?: AxisScaleConfig;
16
21
  };
@@ -19,6 +24,7 @@ export type BaseChartConfig = {
19
24
  */
20
25
  export declare abstract class BaseChart {
21
26
  protected data: DataItem[];
27
+ protected sourceData: ChartData;
22
28
  protected readonly theme: ChartTheme;
23
29
  protected readonly scaleConfig: AxisScaleConfig;
24
30
  protected width: number;
@@ -51,6 +57,7 @@ export declare abstract class BaseChart {
51
57
  * Performs the actual rendering logic
52
58
  */
53
59
  private performRender;
60
+ protected resolveRenderDimensions(containerRect: DOMRect): RenderDimensions;
54
61
  /**
55
62
  * Get layout-aware components in order
56
63
  * Override in subclasses to provide chart-specific components
@@ -73,7 +80,7 @@ export declare abstract class BaseChart {
73
80
  /**
74
81
  * Updates the chart with new data
75
82
  */
76
- update(data: DataItem[]): void;
83
+ update(data: ChartData): void;
77
84
  /**
78
85
  * Destroys the chart and cleans up resources
79
86
  */
package/base-chart.js CHANGED
@@ -6,6 +6,7 @@ import { serializeCSV } from './export-tabular.js';
6
6
  import { exportRasterBlob } from './export-image.js';
7
7
  import { exportXLSXBlob } from './export-xlsx.js';
8
8
  import { exportPDFBlob } from './export-pdf.js';
9
+ import { normalizeChartData } from './grouped-data.js';
9
10
  /**
10
11
  * Base chart class that provides common functionality for all chart types
11
12
  */
@@ -17,6 +18,12 @@ export class BaseChart {
17
18
  writable: true,
18
19
  value: void 0
19
20
  });
21
+ Object.defineProperty(this, "sourceData", {
22
+ enumerable: true,
23
+ configurable: true,
24
+ writable: true,
25
+ value: void 0
26
+ });
20
27
  Object.defineProperty(this, "theme", {
21
28
  enumerable: true,
22
29
  configurable: true,
@@ -125,9 +132,10 @@ export class BaseChart {
125
132
  writable: true,
126
133
  value: null
127
134
  });
128
- // Validate data
129
- ChartValidator.validateData(config.data);
130
- this.data = config.data;
135
+ const normalized = normalizeChartData(config.data);
136
+ ChartValidator.validateData(normalized.data);
137
+ this.sourceData = config.data;
138
+ this.data = normalized.data;
131
139
  this.theme = { ...defaultTheme, ...config.theme };
132
140
  this.width = this.theme.width;
133
141
  this.height = this.theme.height;
@@ -164,18 +172,17 @@ export class BaseChart {
164
172
  * Performs the actual rendering logic
165
173
  */
166
174
  performRender() {
167
- if (!this.container)
175
+ if (!this.container) {
168
176
  return;
169
- // Calculate current width
170
- this.width =
171
- this.container.getBoundingClientRect().width || this.theme.width;
172
- this.height =
173
- this.container.getBoundingClientRect().height || this.theme.height;
177
+ }
178
+ const dimensions = this.resolveRenderDimensions(this.container.getBoundingClientRect());
179
+ this.width = dimensions.width;
180
+ this.height = dimensions.height;
174
181
  // Clear and setup SVG
175
182
  this.container.innerHTML = '';
176
183
  this.svg = create('svg')
177
184
  .attr('width', '100%')
178
- .attr('height', this.height)
185
+ .attr('height', dimensions.svgHeightAttr)
179
186
  .style('display', 'block');
180
187
  this.container.appendChild(this.svg.node());
181
188
  this.prepareLayout();
@@ -193,6 +200,15 @@ export class BaseChart {
193
200
  // Render chart content
194
201
  this.renderChart();
195
202
  }
203
+ resolveRenderDimensions(containerRect) {
204
+ const width = containerRect.width || this.theme.width;
205
+ const height = containerRect.height || this.theme.height;
206
+ return {
207
+ width,
208
+ height,
209
+ svgHeightAttr: '100%',
210
+ };
211
+ }
196
212
  /**
197
213
  * Get layout-aware components in order
198
214
  * Override in subclasses to provide chart-specific components
@@ -298,8 +314,10 @@ export class BaseChart {
298
314
  * Updates the chart with new data
299
315
  */
300
316
  update(data) {
301
- ChartValidator.validateData(data);
302
- this.data = data;
317
+ const normalized = normalizeChartData(data);
318
+ ChartValidator.validateData(normalized.data);
319
+ this.sourceData = data;
320
+ this.data = normalized.data;
303
321
  if (!this.container) {
304
322
  throw new Error('Chart must be rendered before update()');
305
323
  }
@@ -402,7 +420,7 @@ export class BaseChart {
402
420
  return 'application/pdf';
403
421
  }
404
422
  exportCSV(options) {
405
- return serializeCSV(this.data, options);
423
+ return serializeCSV(this.sourceData, options);
406
424
  }
407
425
  exportSize(options) {
408
426
  return {
@@ -411,7 +429,7 @@ export class BaseChart {
411
429
  };
412
430
  }
413
431
  async exportXLSX(options) {
414
- return exportXLSXBlob(this.data, options);
432
+ return exportXLSXBlob(this.sourceData, options);
415
433
  }
416
434
  async exportImage(format, options) {
417
435
  const { width, height } = this.exportSize(options);
@@ -493,10 +511,6 @@ export class BaseChart {
493
511
  return exportSvg.outerHTML;
494
512
  }
495
513
  exportJSON() {
496
- return JSON.stringify({
497
- data: this.data,
498
- theme: this.theme,
499
- scales: this.scaleConfig,
500
- }, null, 2);
514
+ return JSON.stringify(this.sourceData, null, 2);
501
515
  }
502
516
  }
@@ -1,6 +1,6 @@
1
1
  import type { ExportHooks } from './types.js';
2
2
  export interface ChartComponent<TConfig = any> {
3
- type: 'line' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'title' | 'donutCenterContent';
3
+ type: 'line' | 'area' | 'bar' | 'xAxis' | 'yAxis' | 'grid' | 'tooltip' | 'legend' | 'title' | 'donutCenterContent';
4
4
  exportHooks?: ExportHooks<TConfig>;
5
5
  }
6
6
  export type ComponentSpace = {
@@ -28,6 +28,6 @@ export declare class DonutCenterContent implements ChartComponent<DonutCenterCon
28
28
  constructor(config?: DonutCenterContentConfig);
29
29
  getExportConfig(): DonutCenterContentConfigBase;
30
30
  createExportComponent(override?: Partial<DonutCenterContentConfigBase>): ChartComponent;
31
- render(svg: Selection<SVGSVGElement, undefined, null, undefined>, cx: number, cy: number, theme: ChartTheme): void;
31
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, cx: number, cy: number, theme: ChartTheme, fontScale?: number): void;
32
32
  }
33
33
  export {};
@@ -60,14 +60,14 @@ export class DonutCenterContent {
60
60
  exportHooks: this.exportHooks,
61
61
  });
62
62
  }
63
- render(svg, cx, cy, theme) {
63
+ render(svg, cx, cy, theme, fontScale = 1) {
64
64
  const defaults = theme.donut.centerContent;
65
65
  const elements = [];
66
66
  if (this.mainValue) {
67
67
  const style = this.config.mainValueStyle;
68
68
  elements.push({
69
69
  text: this.mainValue,
70
- fontSize: style?.fontSize ?? defaults.mainValue.fontSize,
70
+ fontSize: (style?.fontSize ?? defaults.mainValue.fontSize) * fontScale,
71
71
  fontWeight: style?.fontWeight ?? defaults.mainValue.fontWeight,
72
72
  fontFamily: style?.fontFamily ??
73
73
  defaults.mainValue.fontFamily ??
@@ -79,7 +79,7 @@ export class DonutCenterContent {
79
79
  const style = this.config.titleStyle;
80
80
  elements.push({
81
81
  text: this.title,
82
- fontSize: style?.fontSize ?? defaults.title.fontSize,
82
+ fontSize: (style?.fontSize ?? defaults.title.fontSize) * fontScale,
83
83
  fontWeight: style?.fontWeight ?? defaults.title.fontWeight,
84
84
  fontFamily: style?.fontFamily ??
85
85
  defaults.title.fontFamily ??
@@ -91,7 +91,7 @@ export class DonutCenterContent {
91
91
  const style = this.config.subtitleStyle;
92
92
  elements.push({
93
93
  text: this.subtitle,
94
- fontSize: style?.fontSize ?? defaults.subtitle.fontSize,
94
+ fontSize: (style?.fontSize ?? defaults.subtitle.fontSize) * fontScale,
95
95
  fontWeight: style?.fontWeight ?? defaults.subtitle.fontWeight,
96
96
  fontFamily: style?.fontFamily ??
97
97
  defaults.subtitle.fontFamily ??
@@ -99,9 +99,10 @@ export class DonutCenterContent {
99
99
  color: style?.color ?? defaults.subtitle.color,
100
100
  });
101
101
  }
102
- if (elements.length === 0)
102
+ if (elements.length === 0) {
103
103
  return;
104
- const lineSpacing = 6;
104
+ }
105
+ const lineSpacing = Math.max(2, 6 * fontScale);
105
106
  const totalHeight = elements.reduce((sum, el, i) => sum + el.fontSize + (i < elements.length - 1 ? lineSpacing : 0), 0);
106
107
  const group = svg.append('g').attr('class', 'donut-center-content');
107
108
  let currentY = cy - totalHeight / 2;
package/donut-chart.d.ts CHANGED
@@ -28,6 +28,7 @@ export declare class DonutChart extends BaseChart {
28
28
  protected getLayoutComponents(): LayoutAwareComponent[];
29
29
  protected createExportChart(): BaseChart;
30
30
  protected renderChart(): void;
31
+ private resolveFontScale;
31
32
  private positionTooltip;
32
33
  private buildTooltipContent;
33
34
  private renderSegments;
package/donut-chart.js CHANGED
@@ -157,12 +157,13 @@ export class DonutChart extends BaseChart {
157
157
  const cy = this.plotArea.top + this.plotArea.height / 2;
158
158
  const outerRadius = Math.min(this.plotArea.width, this.plotArea.height) / 2;
159
159
  const innerRadius = outerRadius * this.innerRadiusRatio;
160
+ const fontScale = this.resolveFontScale(outerRadius);
160
161
  if (this.tooltip) {
161
162
  this.tooltip.initialize(this.theme);
162
163
  }
163
164
  this.renderSegments(visibleSegments, cx, cy, innerRadius, outerRadius);
164
165
  if (this.centerContent) {
165
- this.centerContent.render(this.svg, cx, cy, this.theme);
166
+ this.centerContent.render(this.svg, cx, cy, this.theme, fontScale);
166
167
  }
167
168
  if (this.legend) {
168
169
  const pos = this.layoutManager.getComponentPosition(this.legend);
@@ -173,6 +174,12 @@ export class DonutChart extends BaseChart {
173
174
  this.legend.render(this.svg, legendSeries, this.theme, this.width, pos.x, pos.y);
174
175
  }
175
176
  }
177
+ resolveFontScale(outerRadius) {
178
+ const plotHeight = Math.max(1, this.theme.height - this.theme.margins.top - this.theme.margins.bottom);
179
+ const referenceRadius = Math.max(1, plotHeight / 2);
180
+ const rawScale = outerRadius / referenceRadius;
181
+ return Math.max(0.5, Math.min(1, rawScale));
182
+ }
176
183
  positionTooltip(event, tooltipDiv) {
177
184
  const node = tooltipDiv.node();
178
185
  if (!node)
@@ -224,7 +231,7 @@ export class DonutChart extends BaseChart {
224
231
  .append('g')
225
232
  .attr('class', 'donut-segments')
226
233
  .attr('transform', `translate(${cx}, ${cy})`);
227
- const tooltipDiv = this.tooltip
234
+ const resolveTooltipDiv = () => this.tooltip
228
235
  ? select(`#${this.tooltip.id}`)
229
236
  : null;
230
237
  segmentGroup
@@ -245,6 +252,7 @@ export class DonutChart extends BaseChart {
245
252
  .selectAll('.donut-segment')
246
253
  .filter((_, i, nodes) => nodes[i] !== event.currentTarget)
247
254
  .style('opacity', 0.5);
255
+ const tooltipDiv = resolveTooltipDiv();
248
256
  if (tooltipDiv && !tooltipDiv.empty()) {
249
257
  tooltipDiv
250
258
  .style('visibility', 'visible')
@@ -253,6 +261,7 @@ export class DonutChart extends BaseChart {
253
261
  }
254
262
  })
255
263
  .on('mousemove', (event) => {
264
+ const tooltipDiv = resolveTooltipDiv();
256
265
  if (tooltipDiv && !tooltipDiv.empty()) {
257
266
  this.positionTooltip(event, tooltipDiv);
258
267
  }
@@ -263,6 +272,7 @@ export class DonutChart extends BaseChart {
263
272
  .duration(ANIMATION_DURATION_MS)
264
273
  .attr('d', arcGenerator(d));
265
274
  segmentGroup.selectAll('.donut-segment').style('opacity', 1);
275
+ const tooltipDiv = resolveTooltipDiv();
266
276
  if (tooltipDiv && !tooltipDiv.empty()) {
267
277
  tooltipDiv.style('visibility', 'hidden');
268
278
  }