@internetstiftelsen/charts 0.1.1 → 0.3.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 +21 -377
- package/bar.js +142 -30
- package/base-chart.d.ts +3 -2
- package/base-chart.js +3 -6
- package/chart-interface.d.ts +1 -1
- package/donut-center-content.d.ts +27 -0
- package/donut-center-content.js +98 -0
- package/donut-chart.d.ts +32 -0
- package/donut-chart.js +240 -0
- package/legend.d.ts +2 -4
- package/line.js +3 -1
- package/package.json +1 -1
- package/theme.js +44 -0
- package/tooltip.js +58 -19
- package/types.d.ts +33 -0
- package/x-axis.d.ts +4 -0
- package/x-axis.js +87 -2
- package/xy-chart.js +17 -16
- package/y-axis.js +3 -1
package/chart-interface.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/donut-chart.d.ts
ADDED
|
@@ -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:
|
|
21
|
+
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, series: LegendSeries[], theme: ChartTheme, width: number, _x?: number, y?: number): void;
|
|
24
22
|
}
|
package/line.js
CHANGED
|
@@ -50,7 +50,9 @@ export class Line {
|
|
|
50
50
|
case 'time':
|
|
51
51
|
// Time scale - convert to Date
|
|
52
52
|
scaledValue =
|
|
53
|
-
xValue instanceof Date
|
|
53
|
+
xValue instanceof Date
|
|
54
|
+
? xValue
|
|
55
|
+
: new Date(String(xValue));
|
|
54
56
|
break;
|
|
55
57
|
case 'linear':
|
|
56
58
|
case 'log':
|
package/package.json
CHANGED
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/tooltip.js
CHANGED
|
@@ -59,7 +59,6 @@ export class Tooltip {
|
|
|
59
59
|
.style('font-family', theme.axis.fontFamily)
|
|
60
60
|
.style('font-size', '12px')
|
|
61
61
|
.style('pointer-events', 'none')
|
|
62
|
-
.style('transition', 'left 0.1s ease-out, top 0.1s ease-out')
|
|
63
62
|
.style('z-index', '1000');
|
|
64
63
|
}
|
|
65
64
|
attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal = false) {
|
|
@@ -171,8 +170,7 @@ export class Tooltip {
|
|
|
171
170
|
if (hasBarSeries) {
|
|
172
171
|
barSeries.forEach((s) => {
|
|
173
172
|
const sanitizedKey = sanitizeForCSS(s.dataKey);
|
|
174
|
-
svg.selectAll(`.bar-${sanitizedKey}`)
|
|
175
|
-
.style('opacity', (_, i) => i === closestIndex ? 1 : 0.5);
|
|
173
|
+
svg.selectAll(`.bar-${sanitizedKey}`).style('opacity', (_, i) => (i === closestIndex ? 1 : 0.5));
|
|
176
174
|
});
|
|
177
175
|
}
|
|
178
176
|
// Build tooltip content
|
|
@@ -197,33 +195,74 @@ export class Tooltip {
|
|
|
197
195
|
}
|
|
198
196
|
});
|
|
199
197
|
}
|
|
200
|
-
// Position tooltip
|
|
198
|
+
// Position tooltip: X anchored to data point, Y at midpoint of values
|
|
199
|
+
tooltip.style('visibility', 'visible').html(content);
|
|
200
|
+
// Get tooltip dimensions after content is set
|
|
201
|
+
const tooltipNode = tooltip.node();
|
|
202
|
+
const tooltipRect = tooltipNode.getBoundingClientRect();
|
|
203
|
+
const tooltipWidth = tooltipRect.width;
|
|
204
|
+
const tooltipHeight = tooltipRect.height;
|
|
201
205
|
const svgRect = svg.node().getBoundingClientRect();
|
|
206
|
+
const offsetX = 12;
|
|
207
|
+
// Calculate min/max values across all series for this data point
|
|
208
|
+
const values = series.map((s) => parseValue(dataPoint[s.dataKey]));
|
|
209
|
+
const minValue = Math.min(...values);
|
|
210
|
+
const maxValue = Math.max(...values);
|
|
202
211
|
let tooltipX;
|
|
203
212
|
let tooltipY;
|
|
204
213
|
if (isHorizontal) {
|
|
205
|
-
// Horizontal:
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
10;
|
|
214
|
+
// Horizontal: X at midpoint of values, Y anchored to category
|
|
215
|
+
const minX = x(minValue);
|
|
216
|
+
const maxX = x(maxValue);
|
|
217
|
+
const midX = (minX + maxX) / 2;
|
|
218
|
+
tooltipX = svgRect.left + window.scrollX + midX + offsetX;
|
|
211
219
|
tooltipY =
|
|
212
|
-
svgRect.top +
|
|
220
|
+
svgRect.top +
|
|
221
|
+
window.scrollY +
|
|
222
|
+
dataPointPosition -
|
|
223
|
+
tooltipHeight / 2;
|
|
213
224
|
}
|
|
214
225
|
else {
|
|
215
|
-
// Vertical:
|
|
226
|
+
// Vertical: X anchored to category, Y at midpoint of values
|
|
227
|
+
const minY = y(maxValue); // Note: Y scale is inverted
|
|
228
|
+
const maxY = y(minValue);
|
|
229
|
+
const midY = (minY + maxY) / 2;
|
|
216
230
|
tooltipX =
|
|
217
|
-
svgRect.left +
|
|
231
|
+
svgRect.left +
|
|
232
|
+
window.scrollX +
|
|
233
|
+
dataPointPosition +
|
|
234
|
+
offsetX;
|
|
218
235
|
tooltipY =
|
|
219
|
-
svgRect.top +
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
236
|
+
svgRect.top + window.scrollY + midY - tooltipHeight / 2;
|
|
237
|
+
}
|
|
238
|
+
// Edge detection - flip horizontally if approaching right edge
|
|
239
|
+
const viewportWidth = window.innerWidth;
|
|
240
|
+
if (tooltipX + tooltipWidth > viewportWidth - 10) {
|
|
241
|
+
if (isHorizontal) {
|
|
242
|
+
const minX = x(minValue);
|
|
243
|
+
tooltipX =
|
|
244
|
+
svgRect.left +
|
|
245
|
+
window.scrollX +
|
|
246
|
+
minX -
|
|
247
|
+
tooltipWidth -
|
|
248
|
+
offsetX;
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
tooltipX =
|
|
252
|
+
svgRect.left +
|
|
253
|
+
window.scrollX +
|
|
254
|
+
dataPointPosition -
|
|
255
|
+
tooltipWidth -
|
|
256
|
+
offsetX;
|
|
257
|
+
}
|
|
223
258
|
}
|
|
259
|
+
// Ensure tooltip doesn't go off edges
|
|
260
|
+
tooltipX = Math.max(10, tooltipX);
|
|
261
|
+
tooltipY = Math.max(10, Math.min(tooltipY, window.innerHeight +
|
|
262
|
+
window.scrollY -
|
|
263
|
+
tooltipHeight -
|
|
264
|
+
10));
|
|
224
265
|
tooltip
|
|
225
|
-
.style('visibility', 'visible')
|
|
226
|
-
.html(content)
|
|
227
266
|
.style('left', `${tooltipX}px`)
|
|
228
267
|
.style('top', `${tooltipY}px`);
|
|
229
268
|
})
|
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;
|
|
@@ -98,6 +123,9 @@ export type XAxisConfig = {
|
|
|
98
123
|
maxLabelWidth?: number;
|
|
99
124
|
oversizedBehavior?: LabelOversizedBehavior;
|
|
100
125
|
tickFormat?: string | ((value: number) => string) | null;
|
|
126
|
+
autoHideOverlapping?: boolean;
|
|
127
|
+
minLabelGap?: number;
|
|
128
|
+
preserveEndLabels?: boolean;
|
|
101
129
|
};
|
|
102
130
|
export type YAxisConfig = {
|
|
103
131
|
tickFormat?: string | ((value: number) => string) | null;
|
|
@@ -123,6 +151,11 @@ export type LegendConfig = {
|
|
|
123
151
|
marginTop?: number;
|
|
124
152
|
marginBottom?: number;
|
|
125
153
|
};
|
|
154
|
+
export type LegendSeries = {
|
|
155
|
+
dataKey: string;
|
|
156
|
+
stroke?: string;
|
|
157
|
+
fill?: string;
|
|
158
|
+
};
|
|
126
159
|
export type TitleConfig = {
|
|
127
160
|
text: string;
|
|
128
161
|
fontSize?: number;
|
package/x-axis.d.ts
CHANGED
|
@@ -11,6 +11,9 @@ export declare class XAxis implements LayoutAwareComponent {
|
|
|
11
11
|
private readonly oversizedBehavior;
|
|
12
12
|
private readonly tickFormat;
|
|
13
13
|
private wrapLineCount;
|
|
14
|
+
private readonly autoHideOverlapping;
|
|
15
|
+
private readonly minLabelGap;
|
|
16
|
+
private readonly preserveEndLabels;
|
|
14
17
|
constructor(config?: XAxisConfig);
|
|
15
18
|
/**
|
|
16
19
|
* Returns the space required by the x-axis
|
|
@@ -20,4 +23,5 @@ export declare class XAxis implements LayoutAwareComponent {
|
|
|
20
23
|
private applyLabelConstraints;
|
|
21
24
|
private wrapTextElement;
|
|
22
25
|
private addTitleTooltip;
|
|
26
|
+
private applyAutoHiding;
|
|
23
27
|
}
|