@internetstiftelsen/charts 0.0.9 → 0.1.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/bar.d.ts +2 -2
- package/bar.js +16 -10
- package/base-chart.d.ts +12 -5
- package/base-chart.js +35 -5
- package/grid.d.ts +2 -2
- package/line.d.ts +2 -2
- package/line.js +20 -11
- package/package.json +9 -2
- package/tooltip.d.ts +5 -4
- package/tooltip.js +101 -27
- package/types.d.ts +21 -8
- package/utils.d.ts +60 -0
- package/utils.js +165 -0
- package/validation.d.ts +3 -3
- package/x-axis.d.ts +9 -2
- package/x-axis.js +109 -5
- package/xy-chart.d.ts +0 -3
- package/xy-chart.js +4 -29
- package/y-axis.d.ts +8 -2
- package/y-axis.js +103 -7
package/bar.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Selection } from 'd3';
|
|
2
|
-
import type { BarConfig, BarStackingContext, BarValueLabelConfig, ChartTheme, DataItem, Orientation, ScaleType } from './types.js';
|
|
2
|
+
import type { BarConfig, BarStackingContext, BarValueLabelConfig, ChartTheme, D3Scale, DataItem, Orientation, ScaleType } 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";
|
|
@@ -11,7 +11,7 @@ export declare class Bar implements ChartComponent {
|
|
|
11
11
|
readonly valueLabel?: BarValueLabelConfig;
|
|
12
12
|
constructor(config: BarConfig);
|
|
13
13
|
private getScaledPosition;
|
|
14
|
-
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x:
|
|
14
|
+
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext): void;
|
|
15
15
|
private renderVertical;
|
|
16
16
|
private renderHorizontal;
|
|
17
17
|
private renderVerticalValueLabels;
|
package/bar.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeForCSS } from './utils.js';
|
|
1
2
|
export class Bar {
|
|
2
3
|
constructor(config) {
|
|
3
4
|
Object.defineProperty(this, "type", {
|
|
@@ -54,10 +55,11 @@ export class Bar {
|
|
|
54
55
|
let scaledValue;
|
|
55
56
|
switch (scaleType) {
|
|
56
57
|
case 'band':
|
|
57
|
-
scaledValue = value;
|
|
58
|
+
scaledValue = String(value);
|
|
58
59
|
break;
|
|
59
60
|
case 'time':
|
|
60
|
-
scaledValue =
|
|
61
|
+
scaledValue =
|
|
62
|
+
value instanceof Date ? value : new Date(String(value));
|
|
61
63
|
break;
|
|
62
64
|
case 'linear':
|
|
63
65
|
case 'log':
|
|
@@ -130,11 +132,13 @@ export class Bar {
|
|
|
130
132
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
131
133
|
const yBaseline = y(baselineValue) || 0;
|
|
132
134
|
// Add bar rectangles
|
|
135
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
133
136
|
plotGroup
|
|
134
|
-
.selectAll(`.bar-${
|
|
137
|
+
.selectAll(`.bar-${sanitizedKey}`)
|
|
135
138
|
.data(data)
|
|
136
139
|
.join('rect')
|
|
137
|
-
.attr('class', `bar-${
|
|
140
|
+
.attr('class', `bar-${sanitizedKey}`)
|
|
141
|
+
.attr('data-index', (_, i) => i)
|
|
138
142
|
.attr('x', (d) => {
|
|
139
143
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
140
144
|
return xScaleType === 'band'
|
|
@@ -232,11 +236,13 @@ export class Bar {
|
|
|
232
236
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
233
237
|
const xBaseline = x(baselineValue) || 0;
|
|
234
238
|
// Add bar rectangles (horizontal)
|
|
239
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
235
240
|
plotGroup
|
|
236
|
-
.selectAll(`.bar-${
|
|
241
|
+
.selectAll(`.bar-${sanitizedKey}`)
|
|
237
242
|
.data(data)
|
|
238
243
|
.join('rect')
|
|
239
|
-
.attr('class', `bar-${
|
|
244
|
+
.attr('class', `bar-${sanitizedKey}`)
|
|
245
|
+
.attr('data-index', (_, i) => i)
|
|
240
246
|
.attr('x', (d) => {
|
|
241
247
|
const categoryKey = String(d[xKey]);
|
|
242
248
|
const value = parseValue(d[this.dataKey]);
|
|
@@ -339,7 +345,7 @@ export class Bar {
|
|
|
339
345
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
340
346
|
const labelGroup = plotGroup
|
|
341
347
|
.append('g')
|
|
342
|
-
.attr('class', `bar-value-labels-${this.dataKey
|
|
348
|
+
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
343
349
|
data.forEach((d) => {
|
|
344
350
|
const categoryKey = String(d[xKey]);
|
|
345
351
|
const value = parseValue(d[this.dataKey]);
|
|
@@ -380,7 +386,7 @@ export class Bar {
|
|
|
380
386
|
const textBBox = tempText.node().getBBox();
|
|
381
387
|
const boxWidth = textBBox.width + padding * 2;
|
|
382
388
|
const boxHeight = textBBox.height + padding * 2;
|
|
383
|
-
|
|
389
|
+
const labelX = barCenterX;
|
|
384
390
|
let labelY;
|
|
385
391
|
let shouldRender = true;
|
|
386
392
|
if (position === 'outside') {
|
|
@@ -496,7 +502,7 @@ export class Bar {
|
|
|
496
502
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
497
503
|
const labelGroup = plotGroup
|
|
498
504
|
.append('g')
|
|
499
|
-
.attr('class', `bar-value-labels-${this.dataKey
|
|
505
|
+
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
500
506
|
data.forEach((d) => {
|
|
501
507
|
const categoryKey = String(d[xKey]);
|
|
502
508
|
const value = parseValue(d[this.dataKey]);
|
|
@@ -538,7 +544,7 @@ export class Bar {
|
|
|
538
544
|
const boxWidth = textBBox.width + padding * 2;
|
|
539
545
|
const boxHeight = textBBox.height + padding * 2;
|
|
540
546
|
let labelX;
|
|
541
|
-
|
|
547
|
+
const labelY = barCenterY;
|
|
542
548
|
let shouldRender = true;
|
|
543
549
|
if (position === 'outside') {
|
|
544
550
|
// Place to the right of the bar
|
package/base-chart.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { DataItem, ChartTheme, AxisScaleConfig, ExportFormat } from './types.js';
|
|
2
|
+
import type { DataItem, ChartTheme, AxisScaleConfig, ExportFormat, ExportOptions, D3Scale } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
import type { XAxis } from './x-axis.js';
|
|
5
5
|
import type { YAxis } from './y-axis.js';
|
|
@@ -30,8 +30,8 @@ export declare abstract class BaseChart {
|
|
|
30
30
|
protected svg: Selection<SVGSVGElement, undefined, null, undefined> | null;
|
|
31
31
|
protected plotGroup: Selection<SVGGElement, undefined, null, undefined> | null;
|
|
32
32
|
protected container: HTMLElement | null;
|
|
33
|
-
protected x:
|
|
34
|
-
protected y:
|
|
33
|
+
protected x: D3Scale | null;
|
|
34
|
+
protected y: D3Scale | null;
|
|
35
35
|
protected resizeObserver: ResizeObserver | null;
|
|
36
36
|
protected layoutManager: LayoutManager;
|
|
37
37
|
protected plotArea: PlotAreaBounds | null;
|
|
@@ -68,11 +68,18 @@ export declare abstract class BaseChart {
|
|
|
68
68
|
* Destroys the chart and cleans up resources
|
|
69
69
|
*/
|
|
70
70
|
destroy(): void;
|
|
71
|
-
protected parseValue(value:
|
|
71
|
+
protected parseValue(value: unknown): number;
|
|
72
72
|
/**
|
|
73
73
|
* Exports the chart in the specified format
|
|
74
|
+
* @param format - The export format ('svg' or 'json')
|
|
75
|
+
* @param options - Optional export options (download, filename)
|
|
76
|
+
* @returns The exported content as a string if download is false/undefined, void if download is true
|
|
74
77
|
*/
|
|
75
|
-
export(format: ExportFormat): string;
|
|
78
|
+
export(format: ExportFormat, options?: ExportOptions): string | void;
|
|
79
|
+
/**
|
|
80
|
+
* Downloads the exported content as a file
|
|
81
|
+
*/
|
|
82
|
+
private downloadContent;
|
|
76
83
|
protected exportSVG(): string;
|
|
77
84
|
protected exportJSON(): string;
|
|
78
85
|
}
|
package/base-chart.js
CHANGED
|
@@ -222,16 +222,46 @@ export class BaseChart {
|
|
|
222
222
|
this.y = null;
|
|
223
223
|
}
|
|
224
224
|
parseValue(value) {
|
|
225
|
-
|
|
225
|
+
if (typeof value === 'string') {
|
|
226
|
+
return parseFloat(value);
|
|
227
|
+
}
|
|
228
|
+
if (typeof value === 'number') {
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
return 0;
|
|
226
232
|
}
|
|
227
233
|
/**
|
|
228
234
|
* Exports the chart in the specified format
|
|
235
|
+
* @param format - The export format ('svg' or 'json')
|
|
236
|
+
* @param options - Optional export options (download, filename)
|
|
237
|
+
* @returns The exported content as a string if download is false/undefined, void if download is true
|
|
229
238
|
*/
|
|
230
|
-
export(format) {
|
|
231
|
-
|
|
232
|
-
|
|
239
|
+
export(format, options) {
|
|
240
|
+
const content = format === 'svg'
|
|
241
|
+
? this.exportSVG()
|
|
242
|
+
: this.exportJSON();
|
|
243
|
+
if (options?.download) {
|
|
244
|
+
this.downloadContent(content, format, options);
|
|
245
|
+
return;
|
|
233
246
|
}
|
|
234
|
-
return
|
|
247
|
+
return content;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Downloads the exported content as a file
|
|
251
|
+
*/
|
|
252
|
+
downloadContent(content, format, options) {
|
|
253
|
+
const mimeType = format === 'svg'
|
|
254
|
+
? 'image/svg+xml'
|
|
255
|
+
: 'application/json';
|
|
256
|
+
const blob = new Blob([content], { type: mimeType });
|
|
257
|
+
const url = URL.createObjectURL(blob);
|
|
258
|
+
const link = document.createElement('a');
|
|
259
|
+
link.href = url;
|
|
260
|
+
link.download = options.filename || `chart.${format}`;
|
|
261
|
+
document.body.appendChild(link);
|
|
262
|
+
link.click();
|
|
263
|
+
document.body.removeChild(link);
|
|
264
|
+
URL.revokeObjectURL(url);
|
|
235
265
|
}
|
|
236
266
|
exportSVG() {
|
|
237
267
|
if (!this.svg) {
|
package/grid.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { GridConfig, ChartTheme } from './types.js';
|
|
2
|
+
import type { GridConfig, ChartTheme, D3Scale } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
export declare class Grid implements ChartComponent {
|
|
5
5
|
readonly type: "grid";
|
|
6
6
|
readonly horizontal: boolean;
|
|
7
7
|
readonly vertical: boolean;
|
|
8
8
|
constructor(config?: GridConfig);
|
|
9
|
-
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, x:
|
|
9
|
+
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, x: D3Scale, y: D3Scale, theme: ChartTheme): void;
|
|
10
10
|
}
|
package/line.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { LineConfig, DataItem, ScaleType, ChartTheme, LineValueLabelConfig } from './types.js';
|
|
2
|
+
import type { LineConfig, DataItem, D3Scale, 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";
|
|
@@ -8,6 +8,6 @@ export declare class Line implements ChartComponent {
|
|
|
8
8
|
readonly strokeWidth?: number;
|
|
9
9
|
readonly valueLabel?: LineValueLabelConfig;
|
|
10
10
|
constructor(config: LineConfig);
|
|
11
|
-
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x:
|
|
11
|
+
render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: D3Scale, y: D3Scale, parseValue: (value: unknown) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
|
|
12
12
|
private renderValueLabels;
|
|
13
13
|
}
|
package/line.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { line } from 'd3';
|
|
2
|
+
import { sanitizeForCSS } from './utils.js';
|
|
2
3
|
export class Line {
|
|
3
4
|
constructor(config) {
|
|
4
5
|
Object.defineProperty(this, "type", {
|
|
@@ -43,13 +44,13 @@ export class Line {
|
|
|
43
44
|
let scaledValue;
|
|
44
45
|
switch (xScaleType) {
|
|
45
46
|
case 'band':
|
|
46
|
-
// Band scale - use string
|
|
47
|
-
scaledValue = xValue;
|
|
47
|
+
// Band scale - use string
|
|
48
|
+
scaledValue = String(xValue);
|
|
48
49
|
break;
|
|
49
50
|
case 'time':
|
|
50
51
|
// Time scale - convert to Date
|
|
51
52
|
scaledValue =
|
|
52
|
-
xValue instanceof Date ? xValue : new Date(xValue);
|
|
53
|
+
xValue instanceof Date ? xValue : new Date(String(xValue));
|
|
53
54
|
break;
|
|
54
55
|
case 'linear':
|
|
55
56
|
case 'log':
|
|
@@ -62,7 +63,13 @@ export class Line {
|
|
|
62
63
|
// Handle band scales with bandwidth
|
|
63
64
|
return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
|
|
64
65
|
};
|
|
66
|
+
// Helper to check if a data point has a valid (non-null) value
|
|
67
|
+
const hasValidValue = (d) => {
|
|
68
|
+
const value = d[this.dataKey];
|
|
69
|
+
return value !== null && value !== undefined;
|
|
70
|
+
};
|
|
65
71
|
const lineGenerator = line()
|
|
72
|
+
.defined(hasValidValue)
|
|
66
73
|
.x(getXPosition)
|
|
67
74
|
.y((d) => y(parseValue(d[this.dataKey])) || 0);
|
|
68
75
|
const lineStrokeWidth = this.strokeWidth ?? theme.line.strokeWidth;
|
|
@@ -78,21 +85,23 @@ export class Line {
|
|
|
78
85
|
.attr('stroke', this.stroke)
|
|
79
86
|
.attr('stroke-width', lineStrokeWidth)
|
|
80
87
|
.attr('d', lineGenerator);
|
|
81
|
-
// Add data point circles
|
|
88
|
+
// Add data point circles (only for valid values)
|
|
89
|
+
const validData = data.filter(hasValidValue);
|
|
90
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
82
91
|
plotGroup
|
|
83
|
-
.selectAll(`.circle-${
|
|
84
|
-
.data(
|
|
92
|
+
.selectAll(`.circle-${sanitizedKey}`)
|
|
93
|
+
.data(validData)
|
|
85
94
|
.join('circle')
|
|
86
|
-
.attr('class', `circle-${
|
|
95
|
+
.attr('class', `circle-${sanitizedKey}`)
|
|
87
96
|
.attr('cx', getXPosition)
|
|
88
97
|
.attr('cy', (d) => y(parseValue(d[this.dataKey])) || 0)
|
|
89
98
|
.attr('r', pointSize)
|
|
90
99
|
.attr('fill', pointColor)
|
|
91
100
|
.attr('stroke', pointStrokeColor)
|
|
92
101
|
.attr('stroke-width', pointStrokeWidth);
|
|
93
|
-
// Render value labels if enabled
|
|
102
|
+
// Render value labels if enabled (only for valid values)
|
|
94
103
|
if (this.valueLabel?.show) {
|
|
95
|
-
this.renderValueLabels(plotGroup,
|
|
104
|
+
this.renderValueLabels(plotGroup, validData, y, parseValue, theme, getXPosition);
|
|
96
105
|
}
|
|
97
106
|
}
|
|
98
107
|
renderValueLabels(plotGroup, data, y, parseValue, theme, getXPosition) {
|
|
@@ -107,7 +116,7 @@ export class Line {
|
|
|
107
116
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
108
117
|
const labelGroup = plotGroup
|
|
109
118
|
.append('g')
|
|
110
|
-
.attr('class', `line-value-labels-${this.dataKey
|
|
119
|
+
.attr('class', `line-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
111
120
|
const plotTop = y.range()[1];
|
|
112
121
|
const plotBottom = y.range()[0];
|
|
113
122
|
data.forEach((d) => {
|
|
@@ -125,7 +134,7 @@ export class Line {
|
|
|
125
134
|
const textBBox = tempText.node().getBBox();
|
|
126
135
|
const boxWidth = textBBox.width + padding * 2;
|
|
127
136
|
const boxHeight = textBBox.height + padding * 2;
|
|
128
|
-
|
|
137
|
+
const labelX = xPos;
|
|
129
138
|
let labelY;
|
|
130
139
|
let shouldRender = true;
|
|
131
140
|
// Default: place above the point
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.1.1",
|
|
3
3
|
"name": "@internetstiftelsen/charts",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
"build": "tsc -b && vite build",
|
|
20
20
|
"lint": "eslint .",
|
|
21
21
|
"preview": "vite preview",
|
|
22
|
+
"test": "vitest",
|
|
23
|
+
"test:run": "vitest run",
|
|
22
24
|
"build:lib": "tsc --project tsconfig.lib.json && tsc-alias --project tsconfig.lib.json",
|
|
23
25
|
"prepub": "rm -rf dist && npm run build:lib && cp package.json dist && cp README.md dist",
|
|
24
26
|
"pub": "npm run prepub && cd dist && npm publish --access public"
|
|
@@ -43,6 +45,9 @@
|
|
|
43
45
|
},
|
|
44
46
|
"devDependencies": {
|
|
45
47
|
"@eslint/js": "^9.38.0",
|
|
48
|
+
"@testing-library/dom": "^10.4.1",
|
|
49
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
50
|
+
"@testing-library/react": "^16.3.2",
|
|
46
51
|
"@types/node": "^24.9.2",
|
|
47
52
|
"@types/react": "^19.2.2",
|
|
48
53
|
"@types/react-dom": "^19.2.2",
|
|
@@ -51,11 +56,13 @@
|
|
|
51
56
|
"eslint-plugin-react-hooks": "^7.0.1",
|
|
52
57
|
"eslint-plugin-react-refresh": "^0.4.24",
|
|
53
58
|
"globals": "^16.4.0",
|
|
59
|
+
"jsdom": "^27.4.0",
|
|
54
60
|
"prettier": "3.6.2",
|
|
55
61
|
"tsc-alias": "^1.8.16",
|
|
56
62
|
"tw-animate-css": "^1.4.0",
|
|
57
63
|
"typescript": "~5.9.3",
|
|
58
64
|
"typescript-eslint": "^8.46.2",
|
|
59
|
-
"vite": "^7.1.12"
|
|
65
|
+
"vite": "^7.1.12",
|
|
66
|
+
"vitest": "^4.0.17"
|
|
60
67
|
}
|
|
61
68
|
}
|
package/tooltip.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { TooltipConfig, DataItem, ChartTheme } from './types.js';
|
|
2
|
+
import type { TooltipConfig, DataItem, DataValue, D3Scale, ChartTheme } from './types.js';
|
|
3
3
|
import type { ChartComponent } from './chart-interface.js';
|
|
4
4
|
import type { Line } from './line.js';
|
|
5
5
|
import type { Bar } from './bar.js';
|
|
@@ -7,15 +7,16 @@ import type { PlotAreaBounds } from './layout-manager.js';
|
|
|
7
7
|
export declare class Tooltip implements ChartComponent {
|
|
8
8
|
readonly id = "iisChartTooltip";
|
|
9
9
|
readonly type: "tooltip";
|
|
10
|
-
readonly formatter?: (dataKey: string, value:
|
|
10
|
+
readonly formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
|
|
11
11
|
readonly labelFormatter?: (label: string, data: DataItem) => string;
|
|
12
12
|
readonly customFormatter?: (data: DataItem, series: {
|
|
13
13
|
dataKey: string;
|
|
14
|
-
|
|
14
|
+
stroke?: string;
|
|
15
|
+
fill?: string;
|
|
15
16
|
}[]) => string;
|
|
16
17
|
private tooltipDiv;
|
|
17
18
|
constructor(config?: TooltipConfig);
|
|
18
19
|
initialize(theme: ChartTheme): void;
|
|
19
|
-
attachToArea(svg: Selection<SVGSVGElement, undefined, null, undefined>, data: DataItem[], series: (Line | Bar)[], xKey: string, x:
|
|
20
|
+
attachToArea(svg: Selection<SVGSVGElement, undefined, null, undefined>, data: DataItem[], series: (Line | Bar)[], xKey: string, x: D3Scale, y: D3Scale, theme: ChartTheme, plotArea: PlotAreaBounds, parseValue: (value: unknown) => number, isHorizontal?: boolean): void;
|
|
20
21
|
cleanup(): void;
|
|
21
22
|
}
|
package/tooltip.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { pointer, select } from 'd3';
|
|
2
2
|
import { getSeriesColor } from './types.js';
|
|
3
|
+
import { sanitizeForCSS } from './utils.js';
|
|
3
4
|
export class Tooltip {
|
|
4
5
|
constructor(config) {
|
|
5
6
|
Object.defineProperty(this, "id", {
|
|
@@ -58,9 +59,10 @@ export class Tooltip {
|
|
|
58
59
|
.style('font-family', theme.axis.fontFamily)
|
|
59
60
|
.style('font-size', '12px')
|
|
60
61
|
.style('pointer-events', 'none')
|
|
62
|
+
.style('transition', 'left 0.1s ease-out, top 0.1s ease-out')
|
|
61
63
|
.style('z-index', '1000');
|
|
62
64
|
}
|
|
63
|
-
attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue) {
|
|
65
|
+
attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal = false) {
|
|
64
66
|
if (!this.tooltipDiv)
|
|
65
67
|
return;
|
|
66
68
|
const tooltip = this.tooltipDiv;
|
|
@@ -77,6 +79,16 @@ export class Tooltip {
|
|
|
77
79
|
: new Date(xValue));
|
|
78
80
|
return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
|
|
79
81
|
};
|
|
82
|
+
// Helper to get y position for category scale (used in horizontal orientation)
|
|
83
|
+
const getYPosition = (dataPoint) => {
|
|
84
|
+
const yValue = dataPoint[xKey];
|
|
85
|
+
const scaled = y(yValue instanceof Date
|
|
86
|
+
? yValue
|
|
87
|
+
: typeof yValue === 'string'
|
|
88
|
+
? yValue
|
|
89
|
+
: new Date(yValue));
|
|
90
|
+
return (scaled || 0) + (y.bandwidth ? y.bandwidth() / 2 : 0);
|
|
91
|
+
};
|
|
80
92
|
// Create overlay rect for mouse tracking using plot area bounds
|
|
81
93
|
const overlay = svg
|
|
82
94
|
.append('rect')
|
|
@@ -87,12 +99,16 @@ export class Tooltip {
|
|
|
87
99
|
.attr('height', plotArea.height)
|
|
88
100
|
.style('fill', 'none')
|
|
89
101
|
.style('pointer-events', 'all');
|
|
90
|
-
//
|
|
91
|
-
const
|
|
102
|
+
// Determine which series are lines vs bars
|
|
103
|
+
const lineSeries = series.filter((s) => s.type === 'line');
|
|
104
|
+
const barSeries = series.filter((s) => s.type === 'bar');
|
|
105
|
+
const hasBarSeries = barSeries.length > 0;
|
|
106
|
+
// Create focus circles only for line series
|
|
107
|
+
const focusCircles = lineSeries.map((s) => {
|
|
92
108
|
const seriesColor = getSeriesColor(s);
|
|
93
109
|
return svg
|
|
94
110
|
.append('circle')
|
|
95
|
-
.attr('class', `focus-circle-${s.dataKey
|
|
111
|
+
.attr('class', `focus-circle-${sanitizeForCSS(s.dataKey)}`)
|
|
96
112
|
.attr('r', theme.line.point.size + 1)
|
|
97
113
|
.attr('fill', theme.line.point.color || seriesColor)
|
|
98
114
|
.attr('stroke', theme.line.point.strokeColor || seriesColor)
|
|
@@ -102,29 +118,63 @@ export class Tooltip {
|
|
|
102
118
|
});
|
|
103
119
|
overlay
|
|
104
120
|
.on('mousemove', (event) => {
|
|
105
|
-
const [mouseX] = pointer(event, svg.node());
|
|
106
|
-
// Find closest
|
|
107
|
-
const xPositions = data.map((d) => getXPosition(d));
|
|
121
|
+
const [mouseX, mouseY] = pointer(event, svg.node());
|
|
122
|
+
// Find closest data point based on orientation
|
|
108
123
|
let closestIndex = 0;
|
|
109
|
-
let
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
124
|
+
let dataPointPosition;
|
|
125
|
+
if (isHorizontal) {
|
|
126
|
+
// For horizontal charts, find closest by Y position (categories on Y axis)
|
|
127
|
+
const yPositions = data.map((d) => getYPosition(d));
|
|
128
|
+
let minDistance = Math.abs(mouseY - yPositions[0]);
|
|
129
|
+
for (let i = 1; i < yPositions.length; i++) {
|
|
130
|
+
const distance = Math.abs(mouseY - yPositions[i]);
|
|
131
|
+
if (distance < minDistance) {
|
|
132
|
+
minDistance = distance;
|
|
133
|
+
closestIndex = i;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
dataPointPosition = yPositions[closestIndex];
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
// For vertical charts, find closest by X position (categories on X axis)
|
|
140
|
+
const xPositions = data.map((d) => getXPosition(d));
|
|
141
|
+
let minDistance = Math.abs(mouseX - xPositions[0]);
|
|
142
|
+
for (let i = 1; i < xPositions.length; i++) {
|
|
143
|
+
const distance = Math.abs(mouseX - xPositions[i]);
|
|
144
|
+
if (distance < minDistance) {
|
|
145
|
+
minDistance = distance;
|
|
146
|
+
closestIndex = i;
|
|
147
|
+
}
|
|
115
148
|
}
|
|
149
|
+
dataPointPosition = xPositions[closestIndex];
|
|
116
150
|
}
|
|
117
151
|
const dataPoint = data[closestIndex];
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
152
|
+
// Update focus circles for line series
|
|
153
|
+
lineSeries.forEach((s, i) => {
|
|
154
|
+
const value = parseValue(dataPoint[s.dataKey]);
|
|
155
|
+
if (isHorizontal) {
|
|
156
|
+
// Horizontal: cx = value position (X), cy = category position (Y)
|
|
157
|
+
focusCircles[i]
|
|
158
|
+
.attr('cx', x(value))
|
|
159
|
+
.attr('cy', dataPointPosition)
|
|
160
|
+
.style('opacity', 1);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// Vertical: cx = category position (X), cy = value position (Y)
|
|
164
|
+
focusCircles[i]
|
|
165
|
+
.attr('cx', dataPointPosition)
|
|
166
|
+
.attr('cy', y(value))
|
|
167
|
+
.style('opacity', 1);
|
|
168
|
+
}
|
|
127
169
|
});
|
|
170
|
+
// Fade non-hovered bars
|
|
171
|
+
if (hasBarSeries) {
|
|
172
|
+
barSeries.forEach((s) => {
|
|
173
|
+
const sanitizedKey = sanitizeForCSS(s.dataKey);
|
|
174
|
+
svg.selectAll(`.bar-${sanitizedKey}`)
|
|
175
|
+
.style('opacity', (_, i) => i === closestIndex ? 1 : 0.5);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
128
178
|
// Build tooltip content
|
|
129
179
|
let content;
|
|
130
180
|
if (customFormatter) {
|
|
@@ -149,11 +199,28 @@ export class Tooltip {
|
|
|
149
199
|
}
|
|
150
200
|
// Position tooltip relative to the data point
|
|
151
201
|
const svgRect = svg.node().getBoundingClientRect();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
202
|
+
let tooltipX;
|
|
203
|
+
let tooltipY;
|
|
204
|
+
if (isHorizontal) {
|
|
205
|
+
// Horizontal: position near bar end (X = value, Y = category)
|
|
206
|
+
tooltipX =
|
|
207
|
+
svgRect.left +
|
|
208
|
+
window.scrollX +
|
|
209
|
+
x(parseValue(dataPoint[series[0].dataKey])) +
|
|
210
|
+
10;
|
|
211
|
+
tooltipY =
|
|
212
|
+
svgRect.top + window.scrollY + dataPointPosition - 10;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Vertical: position near data point (X = category, Y = value)
|
|
216
|
+
tooltipX =
|
|
217
|
+
svgRect.left + window.scrollX + dataPointPosition + 10;
|
|
218
|
+
tooltipY =
|
|
219
|
+
svgRect.top +
|
|
220
|
+
window.scrollY +
|
|
221
|
+
y(parseValue(dataPoint[series[0].dataKey])) -
|
|
222
|
+
10;
|
|
223
|
+
}
|
|
157
224
|
tooltip
|
|
158
225
|
.style('visibility', 'visible')
|
|
159
226
|
.html(content)
|
|
@@ -163,6 +230,13 @@ export class Tooltip {
|
|
|
163
230
|
.on('mouseout', () => {
|
|
164
231
|
tooltip.style('visibility', 'hidden');
|
|
165
232
|
focusCircles.forEach((circle) => circle.style('opacity', 0));
|
|
233
|
+
// Reset bar opacity
|
|
234
|
+
if (hasBarSeries) {
|
|
235
|
+
barSeries.forEach((s) => {
|
|
236
|
+
const sanitizedKey = sanitizeForCSS(s.dataKey);
|
|
237
|
+
svg.selectAll(`.bar-${sanitizedKey}`).style('opacity', 1);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
166
240
|
});
|
|
167
241
|
}
|
|
168
242
|
cleanup() {
|
package/types.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
export type
|
|
2
|
-
|
|
3
|
-
};
|
|
1
|
+
export type DataValue = string | number | boolean | Date | null | undefined;
|
|
2
|
+
export type DataItem = Record<string, any>;
|
|
4
3
|
export type ExportFormat = 'svg' | 'json';
|
|
4
|
+
export type ExportOptions = {
|
|
5
|
+
download?: boolean;
|
|
6
|
+
filename?: string;
|
|
7
|
+
};
|
|
5
8
|
export type ColorPalette = string[];
|
|
6
9
|
export type ChartTheme = {
|
|
7
10
|
width: number;
|
|
@@ -88,23 +91,31 @@ export declare function getSeriesColor(series: {
|
|
|
88
91
|
stroke?: string;
|
|
89
92
|
fill?: string;
|
|
90
93
|
}): string;
|
|
94
|
+
export type LabelOversizedBehavior = 'truncate' | 'wrap' | 'hide';
|
|
91
95
|
export type XAxisConfig = {
|
|
92
96
|
dataKey?: string;
|
|
93
97
|
rotatedLabels?: boolean;
|
|
98
|
+
maxLabelWidth?: number;
|
|
99
|
+
oversizedBehavior?: LabelOversizedBehavior;
|
|
100
|
+
tickFormat?: string | ((value: number) => string) | null;
|
|
94
101
|
};
|
|
95
102
|
export type YAxisConfig = {
|
|
96
|
-
tickFormat?: string | ((value:
|
|
103
|
+
tickFormat?: string | ((value: number) => string) | null;
|
|
104
|
+
rotatedLabels?: boolean;
|
|
105
|
+
maxLabelWidth?: number;
|
|
106
|
+
oversizedBehavior?: LabelOversizedBehavior;
|
|
97
107
|
};
|
|
98
108
|
export type GridConfig = {
|
|
99
109
|
horizontal?: boolean;
|
|
100
110
|
vertical?: boolean;
|
|
101
111
|
};
|
|
102
112
|
export type TooltipConfig = {
|
|
103
|
-
formatter?: (dataKey: string, value:
|
|
113
|
+
formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
|
|
104
114
|
labelFormatter?: (label: string, data: DataItem) => string;
|
|
105
115
|
customFormatter?: (data: DataItem, series: {
|
|
106
116
|
dataKey: string;
|
|
107
|
-
|
|
117
|
+
stroke?: string;
|
|
118
|
+
fill?: string;
|
|
108
119
|
}[]) => string;
|
|
109
120
|
};
|
|
110
121
|
export type LegendConfig = {
|
|
@@ -122,10 +133,12 @@ export type TitleConfig = {
|
|
|
122
133
|
marginBottom?: number;
|
|
123
134
|
};
|
|
124
135
|
export type ScaleType = 'band' | 'linear' | 'time' | 'log';
|
|
136
|
+
export type D3Scale = any;
|
|
137
|
+
export type ScaleDomainValue = string | number | Date;
|
|
125
138
|
export type ScaleConfig = {
|
|
126
139
|
type: ScaleType;
|
|
127
|
-
domain?:
|
|
128
|
-
range?:
|
|
140
|
+
domain?: ScaleDomainValue[];
|
|
141
|
+
range?: number[];
|
|
129
142
|
padding?: number;
|
|
130
143
|
nice?: boolean;
|
|
131
144
|
min?: number;
|