@internetstiftelsen/charts 0.0.7 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bar.d.ts +7 -2
- package/bar.js +255 -45
- package/line.d.ts +3 -1
- package/line.js +85 -0
- package/package.json +1 -1
- package/theme.js +20 -0
- package/tooltip.d.ts +6 -0
- package/tooltip.js +50 -14
- package/types.d.ts +36 -0
package/bar.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Selection } from 'd3';
|
|
2
|
-
import type { BarConfig, DataItem, ScaleType, Orientation, ChartTheme } from './types.js';
|
|
2
|
+
import type { BarConfig, DataItem, ScaleType, Orientation, ChartTheme, BarValueLabelConfig } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
export declare class Bar implements ChartComponent {
|
|
5
5
|
readonly type: "bar";
|
|
@@ -7,8 +7,13 @@ export declare class Bar implements ChartComponent {
|
|
|
7
7
|
readonly fill: string;
|
|
8
8
|
readonly colorAdapter?: (data: DataItem, index: number) => string;
|
|
9
9
|
readonly orientation: Orientation;
|
|
10
|
+
readonly maxBarSize?: number;
|
|
11
|
+
readonly valueLabel?: BarValueLabelConfig;
|
|
10
12
|
constructor(config: BarConfig);
|
|
11
|
-
|
|
13
|
+
private getScaledPosition;
|
|
14
|
+
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType?: ScaleType, theme?: ChartTheme): void;
|
|
12
15
|
private renderVertical;
|
|
13
16
|
private renderHorizontal;
|
|
17
|
+
private renderVerticalValueLabels;
|
|
18
|
+
private renderHorizontalValueLabels;
|
|
14
19
|
}
|
package/bar.js
CHANGED
|
@@ -30,40 +30,62 @@ export class Bar {
|
|
|
30
30
|
writable: true,
|
|
31
31
|
value: void 0
|
|
32
32
|
});
|
|
33
|
+
Object.defineProperty(this, "maxBarSize", {
|
|
34
|
+
enumerable: true,
|
|
35
|
+
configurable: true,
|
|
36
|
+
writable: true,
|
|
37
|
+
value: void 0
|
|
38
|
+
});
|
|
39
|
+
Object.defineProperty(this, "valueLabel", {
|
|
40
|
+
enumerable: true,
|
|
41
|
+
configurable: true,
|
|
42
|
+
writable: true,
|
|
43
|
+
value: void 0
|
|
44
|
+
});
|
|
33
45
|
this.dataKey = config.dataKey;
|
|
34
46
|
this.fill = config.fill || '#8884d8';
|
|
35
47
|
this.colorAdapter = config.colorAdapter;
|
|
36
48
|
this.orientation = config.orientation || 'vertical';
|
|
49
|
+
this.maxBarSize = config.maxBarSize;
|
|
50
|
+
this.valueLabel = config.valueLabel;
|
|
51
|
+
}
|
|
52
|
+
getScaledPosition(data, key, scale, scaleType) {
|
|
53
|
+
const value = data[key];
|
|
54
|
+
let scaledValue;
|
|
55
|
+
switch (scaleType) {
|
|
56
|
+
case 'band':
|
|
57
|
+
scaledValue = value;
|
|
58
|
+
break;
|
|
59
|
+
case 'time':
|
|
60
|
+
scaledValue = value instanceof Date ? value : new Date(value);
|
|
61
|
+
break;
|
|
62
|
+
case 'linear':
|
|
63
|
+
case 'log':
|
|
64
|
+
scaledValue = typeof value === 'number' ? value : Number(value);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
return scale(scaledValue) || 0;
|
|
37
68
|
}
|
|
38
|
-
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band',
|
|
69
|
+
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
|
|
39
70
|
if (this.orientation === 'vertical') {
|
|
40
71
|
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
41
72
|
}
|
|
42
73
|
else {
|
|
43
74
|
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
44
75
|
}
|
|
76
|
+
// Render value labels if enabled
|
|
77
|
+
if (this.valueLabel?.show && theme) {
|
|
78
|
+
if (this.orientation === 'vertical') {
|
|
79
|
+
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
45
85
|
}
|
|
46
86
|
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType) {
|
|
47
|
-
const getXPosition = (d) => {
|
|
48
|
-
const xValue = d[xKey];
|
|
49
|
-
let scaledValue;
|
|
50
|
-
switch (xScaleType) {
|
|
51
|
-
case 'band':
|
|
52
|
-
scaledValue = xValue;
|
|
53
|
-
break;
|
|
54
|
-
case 'time':
|
|
55
|
-
scaledValue =
|
|
56
|
-
xValue instanceof Date ? xValue : new Date(xValue);
|
|
57
|
-
break;
|
|
58
|
-
case 'linear':
|
|
59
|
-
case 'log':
|
|
60
|
-
scaledValue =
|
|
61
|
-
typeof xValue === 'number' ? xValue : Number(xValue);
|
|
62
|
-
break;
|
|
63
|
-
}
|
|
64
|
-
return x(scaledValue) || 0;
|
|
65
|
-
};
|
|
66
87
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
88
|
+
const barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
|
|
67
89
|
// Get the baseline value from the Y scale's domain
|
|
68
90
|
// For linear scales, use 0 if it's in the domain, otherwise use domain max (bottom of chart)
|
|
69
91
|
// For log scales, use the minimum value from the domain
|
|
@@ -77,15 +99,16 @@ export class Bar {
|
|
|
77
99
|
.join('rect')
|
|
78
100
|
.attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
|
|
79
101
|
.attr('x', (d) => {
|
|
80
|
-
const xPos =
|
|
102
|
+
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
103
|
+
const offset = (bandwidth - barWidth) / 2;
|
|
81
104
|
// For non-band scales, center the bar
|
|
82
|
-
return xScaleType === 'band' ? xPos : xPos -
|
|
105
|
+
return xScaleType === 'band' ? xPos + offset : xPos - barWidth / 2;
|
|
83
106
|
})
|
|
84
107
|
.attr('y', (d) => {
|
|
85
108
|
const yPos = y(parseValue(d[this.dataKey])) || 0;
|
|
86
109
|
return Math.min(yBaseline, yPos);
|
|
87
110
|
})
|
|
88
|
-
.attr('width',
|
|
111
|
+
.attr('width', barWidth)
|
|
89
112
|
.attr('height', (d) => {
|
|
90
113
|
const yPos = y(parseValue(d[this.dataKey])) || 0;
|
|
91
114
|
return Math.abs(yBaseline - yPos);
|
|
@@ -93,26 +116,8 @@ export class Bar {
|
|
|
93
116
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
94
117
|
}
|
|
95
118
|
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType) {
|
|
96
|
-
const getYPosition = (d) => {
|
|
97
|
-
const yValue = d[xKey];
|
|
98
|
-
let scaledValue;
|
|
99
|
-
switch (yScaleType) {
|
|
100
|
-
case 'band':
|
|
101
|
-
scaledValue = yValue;
|
|
102
|
-
break;
|
|
103
|
-
case 'time':
|
|
104
|
-
scaledValue =
|
|
105
|
-
yValue instanceof Date ? yValue : new Date(yValue);
|
|
106
|
-
break;
|
|
107
|
-
case 'linear':
|
|
108
|
-
case 'log':
|
|
109
|
-
scaledValue =
|
|
110
|
-
typeof yValue === 'number' ? yValue : Number(yValue);
|
|
111
|
-
break;
|
|
112
|
-
}
|
|
113
|
-
return y(scaledValue) || 0;
|
|
114
|
-
};
|
|
115
119
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
120
|
+
const barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
|
|
116
121
|
// Get the baseline value from the scale's domain
|
|
117
122
|
// For linear scales, use 0 if it's in the domain, otherwise use domain min
|
|
118
123
|
// For log scales, use the minimum value from the domain
|
|
@@ -130,15 +135,220 @@ export class Bar {
|
|
|
130
135
|
return Math.min(xBaseline, xPos);
|
|
131
136
|
})
|
|
132
137
|
.attr('y', (d) => {
|
|
133
|
-
const yPos =
|
|
138
|
+
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
139
|
+
const offset = (bandwidth - barHeight) / 2;
|
|
134
140
|
// For non-band scales, center the bar
|
|
135
|
-
return yScaleType === 'band' ? yPos : yPos -
|
|
141
|
+
return yScaleType === 'band' ? yPos + offset : yPos - barHeight / 2;
|
|
136
142
|
})
|
|
137
143
|
.attr('width', (d) => {
|
|
138
144
|
const xPos = x(parseValue(d[this.dataKey])) || 0;
|
|
139
145
|
return Math.abs(xPos - xBaseline);
|
|
140
146
|
})
|
|
141
|
-
.attr('height',
|
|
147
|
+
.attr('height', barHeight)
|
|
142
148
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
143
149
|
}
|
|
150
|
+
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme) {
|
|
151
|
+
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
152
|
+
const barWidth = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
|
|
153
|
+
const yDomain = y.domain();
|
|
154
|
+
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
155
|
+
const yBaseline = y(baselineValue) || 0;
|
|
156
|
+
const config = this.valueLabel;
|
|
157
|
+
const position = config.position || 'outside';
|
|
158
|
+
const insidePosition = config.insidePosition || 'top';
|
|
159
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
160
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
161
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
162
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
163
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
164
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
165
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
166
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
167
|
+
const labelGroup = plotGroup
|
|
168
|
+
.append('g')
|
|
169
|
+
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
170
|
+
data.forEach((d) => {
|
|
171
|
+
const value = parseValue(d[this.dataKey]);
|
|
172
|
+
const valueText = String(value);
|
|
173
|
+
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
174
|
+
const yPos = y(value) || 0;
|
|
175
|
+
const barHeight = Math.abs(yBaseline - yPos);
|
|
176
|
+
const barTop = Math.min(yBaseline, yPos);
|
|
177
|
+
const barBottom = Math.max(yBaseline, yPos);
|
|
178
|
+
const barCenterX = xPos + (xScaleType === 'band' ? (bandwidth - barWidth) / 2 : -barWidth / 2) + barWidth / 2;
|
|
179
|
+
// Create temporary text to measure dimensions
|
|
180
|
+
const tempText = labelGroup
|
|
181
|
+
.append('text')
|
|
182
|
+
.style('font-size', `${fontSize}px`)
|
|
183
|
+
.style('font-family', fontFamily)
|
|
184
|
+
.style('font-weight', fontWeight)
|
|
185
|
+
.text(valueText);
|
|
186
|
+
const textBBox = tempText.node().getBBox();
|
|
187
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
188
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
189
|
+
let labelX = barCenterX;
|
|
190
|
+
let labelY;
|
|
191
|
+
let shouldRender = true;
|
|
192
|
+
if (position === 'outside') {
|
|
193
|
+
// Place above the bar
|
|
194
|
+
labelY = barTop - boxHeight / 2 - 4;
|
|
195
|
+
// Check if it fits (not going above plot area)
|
|
196
|
+
const plotTop = y.range()[1];
|
|
197
|
+
if (labelY - boxHeight / 2 < plotTop) {
|
|
198
|
+
shouldRender = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Inside the bar
|
|
203
|
+
switch (insidePosition) {
|
|
204
|
+
case 'top':
|
|
205
|
+
labelY = barTop + boxHeight / 2 + 4;
|
|
206
|
+
break;
|
|
207
|
+
case 'middle':
|
|
208
|
+
labelY = (barTop + barBottom) / 2;
|
|
209
|
+
break;
|
|
210
|
+
case 'bottom':
|
|
211
|
+
labelY = barBottom - boxHeight / 2 - 4;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
// Check if it fits inside the bar
|
|
215
|
+
if (boxHeight + 8 > barHeight) {
|
|
216
|
+
shouldRender = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
tempText.remove();
|
|
220
|
+
if (shouldRender) {
|
|
221
|
+
const group = labelGroup.append('g');
|
|
222
|
+
if (position === 'outside') {
|
|
223
|
+
// Draw rounded rectangle background
|
|
224
|
+
group
|
|
225
|
+
.append('rect')
|
|
226
|
+
.attr('x', labelX - boxWidth / 2)
|
|
227
|
+
.attr('y', labelY - boxHeight / 2)
|
|
228
|
+
.attr('width', boxWidth)
|
|
229
|
+
.attr('height', boxHeight)
|
|
230
|
+
.attr('rx', borderRadius)
|
|
231
|
+
.attr('ry', borderRadius)
|
|
232
|
+
.attr('fill', background)
|
|
233
|
+
.attr('stroke', border)
|
|
234
|
+
.attr('stroke-width', 1);
|
|
235
|
+
}
|
|
236
|
+
// Draw text
|
|
237
|
+
group
|
|
238
|
+
.append('text')
|
|
239
|
+
.attr('x', labelX)
|
|
240
|
+
.attr('y', labelY)
|
|
241
|
+
.attr('text-anchor', 'middle')
|
|
242
|
+
.attr('dominant-baseline', 'central')
|
|
243
|
+
.style('font-size', `${fontSize}px`)
|
|
244
|
+
.style('font-family', fontFamily)
|
|
245
|
+
.style('font-weight', fontWeight)
|
|
246
|
+
.style('fill', color)
|
|
247
|
+
.style('pointer-events', 'none')
|
|
248
|
+
.text(valueText);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme) {
|
|
253
|
+
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
254
|
+
const barHeight = this.maxBarSize ? Math.min(bandwidth, this.maxBarSize) : bandwidth;
|
|
255
|
+
const domain = x.domain();
|
|
256
|
+
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
257
|
+
const xBaseline = x(baselineValue) || 0;
|
|
258
|
+
const config = this.valueLabel;
|
|
259
|
+
const position = config.position || 'outside';
|
|
260
|
+
const insidePosition = config.insidePosition || 'top';
|
|
261
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
262
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
263
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
264
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
265
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
266
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
267
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
268
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
269
|
+
const labelGroup = plotGroup
|
|
270
|
+
.append('g')
|
|
271
|
+
.attr('class', `bar-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
272
|
+
data.forEach((d) => {
|
|
273
|
+
const value = parseValue(d[this.dataKey]);
|
|
274
|
+
const valueText = String(value);
|
|
275
|
+
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
276
|
+
const xPos = x(value) || 0;
|
|
277
|
+
const barWidth = Math.abs(xPos - xBaseline);
|
|
278
|
+
const barLeft = Math.min(xBaseline, xPos);
|
|
279
|
+
const barRight = Math.max(xBaseline, xPos);
|
|
280
|
+
const barCenterY = yPos + (yScaleType === 'band' ? (bandwidth - barHeight) / 2 : -barHeight / 2) + barHeight / 2;
|
|
281
|
+
// Create temporary text to measure dimensions
|
|
282
|
+
const tempText = labelGroup
|
|
283
|
+
.append('text')
|
|
284
|
+
.style('font-size', `${fontSize}px`)
|
|
285
|
+
.style('font-family', fontFamily)
|
|
286
|
+
.style('font-weight', fontWeight)
|
|
287
|
+
.text(valueText);
|
|
288
|
+
const textBBox = tempText.node().getBBox();
|
|
289
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
290
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
291
|
+
let labelX;
|
|
292
|
+
let labelY = barCenterY;
|
|
293
|
+
let shouldRender = true;
|
|
294
|
+
if (position === 'outside') {
|
|
295
|
+
// Place to the right of the bar
|
|
296
|
+
labelX = barRight + boxWidth / 2 + 4;
|
|
297
|
+
// Check if it fits (not going beyond plot area)
|
|
298
|
+
const plotRight = x.range()[1];
|
|
299
|
+
if (labelX + boxWidth / 2 > plotRight) {
|
|
300
|
+
shouldRender = false;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
// Inside the bar - map top/middle/bottom to start/middle/end for horizontal
|
|
305
|
+
switch (insidePosition) {
|
|
306
|
+
case 'top': // start of bar (left side)
|
|
307
|
+
labelX = barLeft + boxWidth / 2 + 4;
|
|
308
|
+
break;
|
|
309
|
+
case 'middle':
|
|
310
|
+
labelX = (barLeft + barRight) / 2;
|
|
311
|
+
break;
|
|
312
|
+
case 'bottom': // end of bar (right side)
|
|
313
|
+
labelX = barRight - boxWidth / 2 - 4;
|
|
314
|
+
break;
|
|
315
|
+
}
|
|
316
|
+
// Check if it fits inside the bar
|
|
317
|
+
if (boxWidth + 8 > barWidth) {
|
|
318
|
+
shouldRender = false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
tempText.remove();
|
|
322
|
+
if (shouldRender) {
|
|
323
|
+
const group = labelGroup.append('g');
|
|
324
|
+
if (position === 'outside') {
|
|
325
|
+
// Draw rounded rectangle background
|
|
326
|
+
group
|
|
327
|
+
.append('rect')
|
|
328
|
+
.attr('x', labelX - boxWidth / 2)
|
|
329
|
+
.attr('y', labelY - boxHeight / 2)
|
|
330
|
+
.attr('width', boxWidth)
|
|
331
|
+
.attr('height', boxHeight)
|
|
332
|
+
.attr('rx', borderRadius)
|
|
333
|
+
.attr('ry', borderRadius)
|
|
334
|
+
.attr('fill', background)
|
|
335
|
+
.attr('stroke', border)
|
|
336
|
+
.attr('stroke-width', 1);
|
|
337
|
+
}
|
|
338
|
+
// Draw text
|
|
339
|
+
group
|
|
340
|
+
.append('text')
|
|
341
|
+
.attr('x', labelX)
|
|
342
|
+
.attr('y', labelY)
|
|
343
|
+
.attr('text-anchor', 'middle')
|
|
344
|
+
.attr('dominant-baseline', 'central')
|
|
345
|
+
.style('font-size', `${fontSize}px`)
|
|
346
|
+
.style('font-family', fontFamily)
|
|
347
|
+
.style('font-weight', fontWeight)
|
|
348
|
+
.style('fill', color)
|
|
349
|
+
.style('pointer-events', 'none')
|
|
350
|
+
.text(valueText);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
144
354
|
}
|
package/line.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { LineConfig, DataItem, ScaleType, ChartTheme } from './types.js';
|
|
2
|
+
import type { LineConfig, DataItem, ScaleType, ChartTheme, LineValueLabelConfig } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
export declare class Line implements ChartComponent {
|
|
5
5
|
readonly type: "line";
|
|
6
6
|
readonly dataKey: string;
|
|
7
7
|
readonly stroke: string;
|
|
8
8
|
readonly strokeWidth?: number;
|
|
9
|
+
readonly valueLabel?: LineValueLabelConfig;
|
|
9
10
|
constructor(config: LineConfig);
|
|
10
11
|
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
|
|
12
|
+
private renderValueLabels;
|
|
11
13
|
}
|
package/line.js
CHANGED
|
@@ -25,9 +25,16 @@ export class Line {
|
|
|
25
25
|
writable: true,
|
|
26
26
|
value: void 0
|
|
27
27
|
});
|
|
28
|
+
Object.defineProperty(this, "valueLabel", {
|
|
29
|
+
enumerable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
value: void 0
|
|
33
|
+
});
|
|
28
34
|
this.dataKey = config.dataKey;
|
|
29
35
|
this.stroke = config.stroke || '#8884d8';
|
|
30
36
|
this.strokeWidth = config.strokeWidth;
|
|
37
|
+
this.valueLabel = config.valueLabel;
|
|
31
38
|
}
|
|
32
39
|
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
|
|
33
40
|
const getXPosition = (d) => {
|
|
@@ -83,5 +90,83 @@ export class Line {
|
|
|
83
90
|
.attr('fill', pointColor)
|
|
84
91
|
.attr('stroke', pointStrokeColor)
|
|
85
92
|
.attr('stroke-width', pointStrokeWidth);
|
|
93
|
+
// Render value labels if enabled
|
|
94
|
+
if (this.valueLabel?.show) {
|
|
95
|
+
this.renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
|
|
99
|
+
const config = this.valueLabel;
|
|
100
|
+
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
101
|
+
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
102
|
+
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
103
|
+
const color = config.color ?? theme.valueLabel.color;
|
|
104
|
+
const background = config.background ?? theme.valueLabel.background;
|
|
105
|
+
const border = config.border ?? theme.valueLabel.border;
|
|
106
|
+
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
107
|
+
const padding = config.padding ?? theme.valueLabel.padding;
|
|
108
|
+
const labelGroup = plotGroup
|
|
109
|
+
.append('g')
|
|
110
|
+
.attr('class', `line-value-labels-${this.dataKey.replace(/\s+/g, '-')}`);
|
|
111
|
+
const plotTop = y.range()[1];
|
|
112
|
+
const plotBottom = y.range()[0];
|
|
113
|
+
data.forEach((d) => {
|
|
114
|
+
const value = parseValue(d[this.dataKey]);
|
|
115
|
+
const valueText = String(value);
|
|
116
|
+
const xPos = getXPosition(d);
|
|
117
|
+
const yPos = y(value) || 0;
|
|
118
|
+
// Create temporary text to measure dimensions
|
|
119
|
+
const tempText = labelGroup
|
|
120
|
+
.append('text')
|
|
121
|
+
.style('font-size', `${fontSize}px`)
|
|
122
|
+
.style('font-family', fontFamily)
|
|
123
|
+
.style('font-weight', fontWeight)
|
|
124
|
+
.text(valueText);
|
|
125
|
+
const textBBox = tempText.node().getBBox();
|
|
126
|
+
const boxWidth = textBBox.width + padding * 2;
|
|
127
|
+
const boxHeight = textBBox.height + padding * 2;
|
|
128
|
+
let labelX = xPos;
|
|
129
|
+
let labelY;
|
|
130
|
+
let shouldRender = true;
|
|
131
|
+
// Default: place above the point
|
|
132
|
+
labelY = yPos - boxHeight / 2 - theme.line.point.size - 4;
|
|
133
|
+
// If too close to top, place below instead
|
|
134
|
+
if (labelY - boxHeight / 2 < plotTop + 4) {
|
|
135
|
+
labelY = yPos + boxHeight / 2 + theme.line.point.size + 4;
|
|
136
|
+
// Check if it fits below
|
|
137
|
+
if (labelY + boxHeight / 2 > plotBottom - 4) {
|
|
138
|
+
shouldRender = false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
tempText.remove();
|
|
142
|
+
if (shouldRender) {
|
|
143
|
+
const group = labelGroup.append('g');
|
|
144
|
+
// Draw rounded rectangle background
|
|
145
|
+
group
|
|
146
|
+
.append('rect')
|
|
147
|
+
.attr('x', labelX - boxWidth / 2)
|
|
148
|
+
.attr('y', labelY - boxHeight / 2)
|
|
149
|
+
.attr('width', boxWidth)
|
|
150
|
+
.attr('height', boxHeight)
|
|
151
|
+
.attr('rx', borderRadius)
|
|
152
|
+
.attr('ry', borderRadius)
|
|
153
|
+
.attr('fill', background)
|
|
154
|
+
.attr('stroke', border)
|
|
155
|
+
.attr('stroke-width', 1);
|
|
156
|
+
// Draw text
|
|
157
|
+
group
|
|
158
|
+
.append('text')
|
|
159
|
+
.attr('x', labelX)
|
|
160
|
+
.attr('y', labelY)
|
|
161
|
+
.attr('text-anchor', 'middle')
|
|
162
|
+
.attr('dominant-baseline', 'central')
|
|
163
|
+
.style('font-size', `${fontSize}px`)
|
|
164
|
+
.style('font-family', fontFamily)
|
|
165
|
+
.style('font-weight', fontWeight)
|
|
166
|
+
.style('fill', color)
|
|
167
|
+
.style('pointer-events', 'none')
|
|
168
|
+
.text(valueText);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
86
171
|
}
|
|
87
172
|
}
|
package/package.json
CHANGED
package/theme.js
CHANGED
|
@@ -40,6 +40,16 @@ export const defaultTheme = {
|
|
|
40
40
|
size: 5,
|
|
41
41
|
},
|
|
42
42
|
},
|
|
43
|
+
valueLabel: {
|
|
44
|
+
fontSize: 12,
|
|
45
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
|
46
|
+
fontWeight: '600',
|
|
47
|
+
color: '#1f2a36',
|
|
48
|
+
background: 'rgba(255, 255, 255, 0.95)',
|
|
49
|
+
border: '#e0e0e0',
|
|
50
|
+
borderRadius: 4,
|
|
51
|
+
padding: 4,
|
|
52
|
+
},
|
|
43
53
|
};
|
|
44
54
|
export const newspaperTheme = {
|
|
45
55
|
width: 928,
|
|
@@ -84,6 +94,16 @@ export const newspaperTheme = {
|
|
|
84
94
|
size: 5,
|
|
85
95
|
},
|
|
86
96
|
},
|
|
97
|
+
valueLabel: {
|
|
98
|
+
fontSize: 11,
|
|
99
|
+
fontFamily: 'Georgia, "Times New Roman", Times, serif',
|
|
100
|
+
fontWeight: '600',
|
|
101
|
+
color: '#1a1a1a',
|
|
102
|
+
background: 'rgba(245, 245, 220, 0.95)',
|
|
103
|
+
border: '#2c2c2c',
|
|
104
|
+
borderRadius: 2,
|
|
105
|
+
padding: 3,
|
|
106
|
+
},
|
|
87
107
|
};
|
|
88
108
|
export const themes = {
|
|
89
109
|
default: defaultTheme,
|
package/tooltip.d.ts
CHANGED
|
@@ -5,8 +5,14 @@ import type { Line } from './line.js';
|
|
|
5
5
|
import type { Bar } from './bar.js';
|
|
6
6
|
import type { PlotAreaBounds } from './layout-manager.js';
|
|
7
7
|
export declare class Tooltip implements ChartComponent {
|
|
8
|
+
readonly id = "iisChartTooltip";
|
|
8
9
|
readonly type: "tooltip";
|
|
9
10
|
readonly formatter?: (dataKey: string, value: any, data: DataItem) => string;
|
|
11
|
+
readonly labelFormatter?: (label: string, data: DataItem) => string;
|
|
12
|
+
readonly customFormatter?: (data: DataItem, series: {
|
|
13
|
+
dataKey: string;
|
|
14
|
+
[key: string]: any;
|
|
15
|
+
}[]) => string;
|
|
10
16
|
private tooltipDiv;
|
|
11
17
|
constructor(config?: TooltipConfig);
|
|
12
18
|
initialize(theme: ChartTheme): void;
|
package/tooltip.js
CHANGED
|
@@ -2,6 +2,12 @@ import { pointer, select } from 'd3';
|
|
|
2
2
|
import { getSeriesColor } from './types.js';
|
|
3
3
|
export class Tooltip {
|
|
4
4
|
constructor(config) {
|
|
5
|
+
Object.defineProperty(this, "id", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
writable: true,
|
|
9
|
+
value: 'iisChartTooltip'
|
|
10
|
+
});
|
|
5
11
|
Object.defineProperty(this, "type", {
|
|
6
12
|
enumerable: true,
|
|
7
13
|
configurable: true,
|
|
@@ -14,6 +20,18 @@ export class Tooltip {
|
|
|
14
20
|
writable: true,
|
|
15
21
|
value: void 0
|
|
16
22
|
});
|
|
23
|
+
Object.defineProperty(this, "labelFormatter", {
|
|
24
|
+
enumerable: true,
|
|
25
|
+
configurable: true,
|
|
26
|
+
writable: true,
|
|
27
|
+
value: void 0
|
|
28
|
+
});
|
|
29
|
+
Object.defineProperty(this, "customFormatter", {
|
|
30
|
+
enumerable: true,
|
|
31
|
+
configurable: true,
|
|
32
|
+
writable: true,
|
|
33
|
+
value: void 0
|
|
34
|
+
});
|
|
17
35
|
Object.defineProperty(this, "tooltipDiv", {
|
|
18
36
|
enumerable: true,
|
|
19
37
|
configurable: true,
|
|
@@ -21,11 +39,15 @@ export class Tooltip {
|
|
|
21
39
|
value: null
|
|
22
40
|
});
|
|
23
41
|
this.formatter = config?.formatter;
|
|
42
|
+
this.labelFormatter = config?.labelFormatter;
|
|
43
|
+
this.customFormatter = config?.customFormatter;
|
|
24
44
|
}
|
|
25
45
|
initialize(theme) {
|
|
46
|
+
this.cleanup();
|
|
26
47
|
this.tooltipDiv = select('body')
|
|
27
48
|
.append('div')
|
|
28
49
|
.attr('class', 'chart-tooltip')
|
|
50
|
+
.attr('id', this.id)
|
|
29
51
|
.style('position', 'absolute')
|
|
30
52
|
.style('visibility', 'hidden')
|
|
31
53
|
.style('background-color', 'white')
|
|
@@ -43,6 +65,8 @@ export class Tooltip {
|
|
|
43
65
|
return;
|
|
44
66
|
const tooltip = this.tooltipDiv;
|
|
45
67
|
const formatter = this.formatter;
|
|
68
|
+
const labelFormatter = this.labelFormatter;
|
|
69
|
+
const customFormatter = this.customFormatter;
|
|
46
70
|
// Helper to get x position for any scale type
|
|
47
71
|
const getXPosition = (dataPoint) => {
|
|
48
72
|
const xValue = dataPoint[xKey];
|
|
@@ -102,17 +126,27 @@ export class Tooltip {
|
|
|
102
126
|
.style('opacity', 1);
|
|
103
127
|
});
|
|
104
128
|
// Build tooltip content
|
|
105
|
-
let content
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
129
|
+
let content;
|
|
130
|
+
if (customFormatter) {
|
|
131
|
+
content = customFormatter(dataPoint, series);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const label = labelFormatter
|
|
135
|
+
? labelFormatter(dataPoint[xKey], dataPoint)
|
|
136
|
+
: dataPoint[xKey];
|
|
137
|
+
content = `<strong>${label}</strong><br/>`;
|
|
138
|
+
series.forEach((s) => {
|
|
139
|
+
const value = dataPoint[s.dataKey];
|
|
140
|
+
if (formatter) {
|
|
141
|
+
content +=
|
|
142
|
+
formatter(s.dataKey, value, dataPoint) +
|
|
143
|
+
'<br/>';
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
content += `${s.dataKey}: ${value}<br/>`;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
116
150
|
// Position tooltip relative to the data point
|
|
117
151
|
const svgRect = svg.node().getBoundingClientRect();
|
|
118
152
|
const tooltipX = svgRect.left + window.scrollX + xPos + 10;
|
|
@@ -132,9 +166,11 @@ export class Tooltip {
|
|
|
132
166
|
});
|
|
133
167
|
}
|
|
134
168
|
cleanup() {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
169
|
+
const tooltip = select(`#${this.id}`);
|
|
170
|
+
if (tooltip.empty()) {
|
|
171
|
+
return;
|
|
138
172
|
}
|
|
173
|
+
tooltip.remove();
|
|
174
|
+
this.tooltipDiv = null;
|
|
139
175
|
}
|
|
140
176
|
}
|
package/types.d.ts
CHANGED
|
@@ -36,17 +36,48 @@ export type ChartTheme = {
|
|
|
36
36
|
size: number;
|
|
37
37
|
};
|
|
38
38
|
};
|
|
39
|
+
valueLabel: {
|
|
40
|
+
fontSize: number;
|
|
41
|
+
fontFamily: string;
|
|
42
|
+
fontWeight: string;
|
|
43
|
+
color: string;
|
|
44
|
+
background: string;
|
|
45
|
+
border: string;
|
|
46
|
+
borderRadius: number;
|
|
47
|
+
padding: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export type ValueLabelConfig = {
|
|
51
|
+
fontSize?: number;
|
|
52
|
+
fontFamily?: string;
|
|
53
|
+
fontWeight?: string;
|
|
54
|
+
color?: string;
|
|
55
|
+
background?: string;
|
|
56
|
+
border?: string;
|
|
57
|
+
borderRadius?: number;
|
|
58
|
+
padding?: number;
|
|
59
|
+
};
|
|
60
|
+
export type LineValueLabelConfig = ValueLabelConfig & {
|
|
61
|
+
show?: boolean;
|
|
62
|
+
};
|
|
63
|
+
export type BarValueLabelConfig = ValueLabelConfig & {
|
|
64
|
+
show?: boolean;
|
|
65
|
+
position?: 'inside' | 'outside';
|
|
66
|
+
insidePosition?: 'top' | 'middle' | 'bottom';
|
|
39
67
|
};
|
|
40
68
|
export type LineConfig = {
|
|
41
69
|
dataKey: string;
|
|
42
70
|
stroke?: string;
|
|
43
71
|
strokeWidth?: number;
|
|
72
|
+
valueLabel?: LineValueLabelConfig;
|
|
44
73
|
};
|
|
45
74
|
export type BarConfig = {
|
|
46
75
|
dataKey: string;
|
|
47
76
|
fill?: string;
|
|
48
77
|
colorAdapter?: (data: DataItem, index: number) => string;
|
|
49
78
|
orientation?: 'vertical' | 'horizontal';
|
|
79
|
+
maxBarSize?: number;
|
|
80
|
+
valueLabel?: BarValueLabelConfig;
|
|
50
81
|
};
|
|
51
82
|
export declare function getSeriesColor(series: {
|
|
52
83
|
stroke?: string;
|
|
@@ -65,6 +96,11 @@ export type GridConfig = {
|
|
|
65
96
|
};
|
|
66
97
|
export type TooltipConfig = {
|
|
67
98
|
formatter?: (dataKey: string, value: any, data: DataItem) => string;
|
|
99
|
+
labelFormatter?: (label: string, data: DataItem) => string;
|
|
100
|
+
customFormatter?: (data: DataItem, series: {
|
|
101
|
+
dataKey: string;
|
|
102
|
+
[key: string]: any;
|
|
103
|
+
}[]) => string;
|
|
68
104
|
};
|
|
69
105
|
export type LegendConfig = {
|
|
70
106
|
position?: 'bottom';
|