@internetstiftelsen/charts 0.0.9 → 0.1.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/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 +9 -7
- package/package.json +9 -2
- package/tooltip.d.ts +5 -4
- package/tooltip.js +101 -27
- package/types.d.ts +20 -8
- package/utils.d.ts +60 -0
- package/utils.js +165 -0
- package/validation.d.ts +3 -3
- package/x-axis.d.ts +8 -2
- package/x-axis.js +87 -1
- package/xy-chart.js +1 -1
- 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':
|
|
@@ -79,11 +80,12 @@ export class Line {
|
|
|
79
80
|
.attr('stroke-width', lineStrokeWidth)
|
|
80
81
|
.attr('d', lineGenerator);
|
|
81
82
|
// Add data point circles
|
|
83
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
82
84
|
plotGroup
|
|
83
|
-
.selectAll(`.circle-${
|
|
85
|
+
.selectAll(`.circle-${sanitizedKey}`)
|
|
84
86
|
.data(data)
|
|
85
87
|
.join('circle')
|
|
86
|
-
.attr('class', `circle-${
|
|
88
|
+
.attr('class', `circle-${sanitizedKey}`)
|
|
87
89
|
.attr('cx', getXPosition)
|
|
88
90
|
.attr('cy', (d) => y(parseValue(d[this.dataKey])) || 0)
|
|
89
91
|
.attr('r', pointSize)
|
|
@@ -107,7 +109,7 @@ export class Line {
|
|
|
107
109
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
108
110
|
const labelGroup = plotGroup
|
|
109
111
|
.append('g')
|
|
110
|
-
.attr('class', `line-value-labels-${this.dataKey
|
|
112
|
+
.attr('class', `line-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
111
113
|
const plotTop = y.range()[1];
|
|
112
114
|
const plotBottom = y.range()[0];
|
|
113
115
|
data.forEach((d) => {
|
|
@@ -125,7 +127,7 @@ export class Line {
|
|
|
125
127
|
const textBBox = tempText.node().getBBox();
|
|
126
128
|
const boxWidth = textBBox.width + padding * 2;
|
|
127
129
|
const boxHeight = textBBox.height + padding * 2;
|
|
128
|
-
|
|
130
|
+
const labelX = xPos;
|
|
129
131
|
let labelY;
|
|
130
132
|
let shouldRender = true;
|
|
131
133
|
// Default: place above the point
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.0
|
|
2
|
+
"version": "0.1.0",
|
|
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,30 @@ 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;
|
|
94
100
|
};
|
|
95
101
|
export type YAxisConfig = {
|
|
96
|
-
tickFormat?: string | ((value:
|
|
102
|
+
tickFormat?: string | ((value: number) => string) | null;
|
|
103
|
+
rotatedLabels?: boolean;
|
|
104
|
+
maxLabelWidth?: number;
|
|
105
|
+
oversizedBehavior?: LabelOversizedBehavior;
|
|
97
106
|
};
|
|
98
107
|
export type GridConfig = {
|
|
99
108
|
horizontal?: boolean;
|
|
100
109
|
vertical?: boolean;
|
|
101
110
|
};
|
|
102
111
|
export type TooltipConfig = {
|
|
103
|
-
formatter?: (dataKey: string, value:
|
|
112
|
+
formatter?: (dataKey: string, value: DataValue, data: DataItem) => string;
|
|
104
113
|
labelFormatter?: (label: string, data: DataItem) => string;
|
|
105
114
|
customFormatter?: (data: DataItem, series: {
|
|
106
115
|
dataKey: string;
|
|
107
|
-
|
|
116
|
+
stroke?: string;
|
|
117
|
+
fill?: string;
|
|
108
118
|
}[]) => string;
|
|
109
119
|
};
|
|
110
120
|
export type LegendConfig = {
|
|
@@ -122,10 +132,12 @@ export type TitleConfig = {
|
|
|
122
132
|
marginBottom?: number;
|
|
123
133
|
};
|
|
124
134
|
export type ScaleType = 'band' | 'linear' | 'time' | 'log';
|
|
135
|
+
export type D3Scale = any;
|
|
136
|
+
export type ScaleDomainValue = string | number | Date;
|
|
125
137
|
export type ScaleConfig = {
|
|
126
138
|
type: ScaleType;
|
|
127
|
-
domain?:
|
|
128
|
-
range?:
|
|
139
|
+
domain?: ScaleDomainValue[];
|
|
140
|
+
range?: number[];
|
|
129
141
|
padding?: number;
|
|
130
142
|
nice?: boolean;
|
|
131
143
|
min?: number;
|
package/utils.d.ts
CHANGED
|
@@ -1,2 +1,62 @@
|
|
|
1
1
|
import { type ClassValue } from 'clsx';
|
|
2
2
|
export declare function cn(...inputs: ClassValue[]): string;
|
|
3
|
+
/**
|
|
4
|
+
* Sanitizes a string to be used as a CSS class name or ID.
|
|
5
|
+
* Removes or replaces invalid characters for CSS selectors.
|
|
6
|
+
*
|
|
7
|
+
* @param str - The string to sanitize
|
|
8
|
+
* @returns A valid CSS class/ID string
|
|
9
|
+
*/
|
|
10
|
+
export declare function sanitizeForCSS(str: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Measures the width of text in pixels using an SVG text element.
|
|
13
|
+
*
|
|
14
|
+
* @param text - The text to measure
|
|
15
|
+
* @param fontSize - Font size (e.g., '14px' or 14)
|
|
16
|
+
* @param fontFamily - Font family
|
|
17
|
+
* @param fontWeight - Font weight (optional)
|
|
18
|
+
* @param svg - SVG element to use for measurement
|
|
19
|
+
* @returns Width in pixels
|
|
20
|
+
*/
|
|
21
|
+
export declare function measureTextWidth(text: string, fontSize: string | number, fontFamily: string, fontWeight: string | undefined, svg: SVGSVGElement): number;
|
|
22
|
+
/**
|
|
23
|
+
* Truncates text to fit within a maximum width, adding ellipsis if needed.
|
|
24
|
+
* Uses binary search for efficiency.
|
|
25
|
+
*
|
|
26
|
+
* @param text - The text to truncate
|
|
27
|
+
* @param maxWidth - Maximum width in pixels
|
|
28
|
+
* @param fontSize - Font size
|
|
29
|
+
* @param fontFamily - Font family
|
|
30
|
+
* @param fontWeight - Font weight
|
|
31
|
+
* @param svg - SVG element for measurement
|
|
32
|
+
* @returns Object with truncated text and whether truncation occurred
|
|
33
|
+
*/
|
|
34
|
+
export declare function truncateText(text: string, maxWidth: number, fontSize: string | number, fontFamily: string, fontWeight: string, svg: SVGSVGElement): {
|
|
35
|
+
text: string;
|
|
36
|
+
truncated: boolean;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Breaks a word into multiple lines by character to fit within maxWidth.
|
|
40
|
+
* Uses binary search to find optimal break points.
|
|
41
|
+
*
|
|
42
|
+
* @param word - The word to break
|
|
43
|
+
* @param maxWidth - Maximum width in pixels
|
|
44
|
+
* @param fontSize - Font size
|
|
45
|
+
* @param fontFamily - Font family
|
|
46
|
+
* @param fontWeight - Font weight
|
|
47
|
+
* @param svg - SVG element for measurement
|
|
48
|
+
* @returns Array of line segments
|
|
49
|
+
*/
|
|
50
|
+
export declare function breakWord(word: string, maxWidth: number, fontSize: string | number, fontFamily: string, fontWeight: string, svg: SVGSVGElement): string[];
|
|
51
|
+
/**
|
|
52
|
+
* Wraps text into multiple lines to fit within a maximum width.
|
|
53
|
+
*
|
|
54
|
+
* @param text - The text to wrap
|
|
55
|
+
* @param maxWidth - Maximum width in pixels
|
|
56
|
+
* @param fontSize - Font size
|
|
57
|
+
* @param fontFamily - Font family
|
|
58
|
+
* @param fontWeight - Font weight
|
|
59
|
+
* @param svg - SVG element for measurement
|
|
60
|
+
* @returns Array of lines
|
|
61
|
+
*/
|
|
62
|
+
export declare function wrapText(text: string, maxWidth: number, fontSize: string | number, fontFamily: string, fontWeight: string, svg: SVGSVGElement): string[];
|
package/utils.js
CHANGED
|
@@ -3,3 +3,168 @@ import { twMerge } from 'tailwind-merge';
|
|
|
3
3
|
export function cn(...inputs) {
|
|
4
4
|
return twMerge(clsx(inputs));
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* Sanitizes a string to be used as a CSS class name or ID.
|
|
8
|
+
* Removes or replaces invalid characters for CSS selectors.
|
|
9
|
+
*
|
|
10
|
+
* @param str - The string to sanitize
|
|
11
|
+
* @returns A valid CSS class/ID string
|
|
12
|
+
*/
|
|
13
|
+
export function sanitizeForCSS(str) {
|
|
14
|
+
// Replace spaces and special characters with hyphens
|
|
15
|
+
// Preserve Unicode letters (including Swedish characters), numbers, hyphens, and underscores
|
|
16
|
+
return str
|
|
17
|
+
.replace(/[^\p{L}\p{N}_-]/gu, '-') // Replace invalid chars with hyphens (preserves Unicode letters)
|
|
18
|
+
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
|
19
|
+
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
|
|
20
|
+
.toLowerCase(); // Convert to lowercase for consistency
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Measures the width of text in pixels using an SVG text element.
|
|
24
|
+
*
|
|
25
|
+
* @param text - The text to measure
|
|
26
|
+
* @param fontSize - Font size (e.g., '14px' or 14)
|
|
27
|
+
* @param fontFamily - Font family
|
|
28
|
+
* @param fontWeight - Font weight (optional)
|
|
29
|
+
* @param svg - SVG element to use for measurement
|
|
30
|
+
* @returns Width in pixels
|
|
31
|
+
*/
|
|
32
|
+
export function measureTextWidth(text, fontSize, fontFamily, fontWeight = 'normal', svg) {
|
|
33
|
+
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
34
|
+
textEl.setAttribute('font-size', typeof fontSize === 'number' ? `${fontSize}px` : fontSize);
|
|
35
|
+
textEl.setAttribute('font-family', fontFamily);
|
|
36
|
+
textEl.setAttribute('font-weight', fontWeight);
|
|
37
|
+
textEl.textContent = text;
|
|
38
|
+
textEl.style.visibility = 'hidden';
|
|
39
|
+
svg.appendChild(textEl);
|
|
40
|
+
const width = textEl.getBBox().width;
|
|
41
|
+
svg.removeChild(textEl);
|
|
42
|
+
return width;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Truncates text to fit within a maximum width, adding ellipsis if needed.
|
|
46
|
+
* Uses binary search for efficiency.
|
|
47
|
+
*
|
|
48
|
+
* @param text - The text to truncate
|
|
49
|
+
* @param maxWidth - Maximum width in pixels
|
|
50
|
+
* @param fontSize - Font size
|
|
51
|
+
* @param fontFamily - Font family
|
|
52
|
+
* @param fontWeight - Font weight
|
|
53
|
+
* @param svg - SVG element for measurement
|
|
54
|
+
* @returns Object with truncated text and whether truncation occurred
|
|
55
|
+
*/
|
|
56
|
+
export function truncateText(text, maxWidth, fontSize, fontFamily, fontWeight, svg) {
|
|
57
|
+
const fullWidth = measureTextWidth(text, fontSize, fontFamily, fontWeight, svg);
|
|
58
|
+
if (fullWidth <= maxWidth) {
|
|
59
|
+
return { text, truncated: false };
|
|
60
|
+
}
|
|
61
|
+
const ellipsis = '…';
|
|
62
|
+
const ellipsisWidth = measureTextWidth(ellipsis, fontSize, fontFamily, fontWeight, svg);
|
|
63
|
+
const targetWidth = maxWidth - ellipsisWidth;
|
|
64
|
+
if (targetWidth <= 0) {
|
|
65
|
+
return { text: ellipsis, truncated: true };
|
|
66
|
+
}
|
|
67
|
+
// Binary search to find the maximum number of characters that fit
|
|
68
|
+
let low = 0;
|
|
69
|
+
let high = text.length;
|
|
70
|
+
let result = '';
|
|
71
|
+
while (low <= high) {
|
|
72
|
+
const mid = Math.floor((low + high) / 2);
|
|
73
|
+
const substring = text.substring(0, mid);
|
|
74
|
+
const width = measureTextWidth(substring, fontSize, fontFamily, fontWeight, svg);
|
|
75
|
+
if (width <= targetWidth) {
|
|
76
|
+
result = substring;
|
|
77
|
+
low = mid + 1;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
high = mid - 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { text: result + ellipsis, truncated: true };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Breaks a word into multiple lines by character to fit within maxWidth.
|
|
87
|
+
* Uses binary search to find optimal break points.
|
|
88
|
+
*
|
|
89
|
+
* @param word - The word to break
|
|
90
|
+
* @param maxWidth - Maximum width in pixels
|
|
91
|
+
* @param fontSize - Font size
|
|
92
|
+
* @param fontFamily - Font family
|
|
93
|
+
* @param fontWeight - Font weight
|
|
94
|
+
* @param svg - SVG element for measurement
|
|
95
|
+
* @returns Array of line segments
|
|
96
|
+
*/
|
|
97
|
+
export function breakWord(word, maxWidth, fontSize, fontFamily, fontWeight, svg) {
|
|
98
|
+
const segments = [];
|
|
99
|
+
let remaining = word;
|
|
100
|
+
while (remaining.length > 0) {
|
|
101
|
+
// Binary search to find max characters that fit
|
|
102
|
+
let low = 1;
|
|
103
|
+
let high = remaining.length;
|
|
104
|
+
let fitLength = 1;
|
|
105
|
+
while (low <= high) {
|
|
106
|
+
const mid = Math.floor((low + high) / 2);
|
|
107
|
+
const substring = remaining.substring(0, mid);
|
|
108
|
+
const width = measureTextWidth(substring, fontSize, fontFamily, fontWeight, svg);
|
|
109
|
+
if (width <= maxWidth) {
|
|
110
|
+
fitLength = mid;
|
|
111
|
+
low = mid + 1;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
high = mid - 1;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Ensure at least one character per line to avoid infinite loop
|
|
118
|
+
fitLength = Math.max(1, fitLength);
|
|
119
|
+
segments.push(remaining.substring(0, fitLength));
|
|
120
|
+
remaining = remaining.substring(fitLength);
|
|
121
|
+
}
|
|
122
|
+
return segments;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Wraps text into multiple lines to fit within a maximum width.
|
|
126
|
+
*
|
|
127
|
+
* @param text - The text to wrap
|
|
128
|
+
* @param maxWidth - Maximum width in pixels
|
|
129
|
+
* @param fontSize - Font size
|
|
130
|
+
* @param fontFamily - Font family
|
|
131
|
+
* @param fontWeight - Font weight
|
|
132
|
+
* @param svg - SVG element for measurement
|
|
133
|
+
* @returns Array of lines
|
|
134
|
+
*/
|
|
135
|
+
export function wrapText(text, maxWidth, fontSize, fontFamily, fontWeight, svg) {
|
|
136
|
+
const words = text.split(/\s+/);
|
|
137
|
+
const lines = [];
|
|
138
|
+
let currentLine = '';
|
|
139
|
+
for (const word of words) {
|
|
140
|
+
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
|
141
|
+
const width = measureTextWidth(testLine, fontSize, fontFamily, fontWeight, svg);
|
|
142
|
+
if (width <= maxWidth) {
|
|
143
|
+
currentLine = testLine;
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
if (currentLine) {
|
|
147
|
+
lines.push(currentLine);
|
|
148
|
+
}
|
|
149
|
+
// Check if single word exceeds maxWidth
|
|
150
|
+
const wordWidth = measureTextWidth(word, fontSize, fontFamily, fontWeight, svg);
|
|
151
|
+
if (wordWidth > maxWidth) {
|
|
152
|
+
// Break the word into multiple lines by character
|
|
153
|
+
const wordSegments = breakWord(word, maxWidth, fontSize, fontFamily, fontWeight, svg);
|
|
154
|
+
// Add all segments except the last as complete lines
|
|
155
|
+
for (let i = 0; i < wordSegments.length - 1; i++) {
|
|
156
|
+
lines.push(wordSegments[i]);
|
|
157
|
+
}
|
|
158
|
+
// The last segment becomes the current line (may be combined with next word)
|
|
159
|
+
currentLine = wordSegments[wordSegments.length - 1];
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
currentLine = word;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (currentLine) {
|
|
167
|
+
lines.push(currentLine);
|
|
168
|
+
}
|
|
169
|
+
return lines.length > 0 ? lines : [text];
|
|
170
|
+
}
|
package/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DataItem } from './types.js';
|
|
1
|
+
import type { DataItem, ScaleDomainValue } from './types.js';
|
|
2
2
|
export declare class ChartValidationError extends Error {
|
|
3
3
|
constructor(message: string);
|
|
4
4
|
}
|
|
@@ -9,7 +9,7 @@ export declare class ChartValidator {
|
|
|
9
9
|
/**
|
|
10
10
|
* Validates that data is an array and not empty
|
|
11
11
|
*/
|
|
12
|
-
static validateData(data:
|
|
12
|
+
static validateData(data: unknown): asserts data is DataItem[];
|
|
13
13
|
/**
|
|
14
14
|
* Validates that the specified dataKey exists in all data items
|
|
15
15
|
*/
|
|
@@ -21,7 +21,7 @@ export declare class ChartValidator {
|
|
|
21
21
|
/**
|
|
22
22
|
* Validates scale configuration
|
|
23
23
|
*/
|
|
24
|
-
static validateScaleConfig(scaleType: string, domain:
|
|
24
|
+
static validateScaleConfig(scaleType: string, domain: ScaleDomainValue[] | undefined): void;
|
|
25
25
|
/**
|
|
26
26
|
* Warns about potential issues without throwing errors
|
|
27
27
|
*/
|
package/x-axis.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { XAxisConfig, ChartTheme } from './types.js';
|
|
2
|
+
import type { XAxisConfig, ChartTheme, D3Scale } from './types.js';
|
|
3
3
|
import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
|
|
4
4
|
export declare class XAxis implements LayoutAwareComponent {
|
|
5
5
|
readonly type: "xAxis";
|
|
@@ -7,10 +7,16 @@ export declare class XAxis implements LayoutAwareComponent {
|
|
|
7
7
|
private readonly rotatedLabels;
|
|
8
8
|
private readonly tickPadding;
|
|
9
9
|
private readonly fontSize;
|
|
10
|
+
private readonly maxLabelWidth?;
|
|
11
|
+
private readonly oversizedBehavior;
|
|
12
|
+
private wrapLineCount;
|
|
10
13
|
constructor(config?: XAxisConfig);
|
|
11
14
|
/**
|
|
12
15
|
* Returns the space required by the x-axis
|
|
13
16
|
*/
|
|
14
17
|
getRequiredSpace(): ComponentSpace;
|
|
15
|
-
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x:
|
|
18
|
+
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number): void;
|
|
19
|
+
private applyLabelConstraints;
|
|
20
|
+
private wrapTextElement;
|
|
21
|
+
private addTitleTooltip;
|
|
16
22
|
}
|
package/x-axis.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { axisBottom } from 'd3';
|
|
2
|
+
import { measureTextWidth, truncateText, wrapText } from './utils.js';
|
|
2
3
|
export class XAxis {
|
|
3
4
|
constructor(config) {
|
|
4
5
|
Object.defineProperty(this, "type", {
|
|
@@ -31,8 +32,28 @@ export class XAxis {
|
|
|
31
32
|
writable: true,
|
|
32
33
|
value: 14
|
|
33
34
|
});
|
|
35
|
+
Object.defineProperty(this, "maxLabelWidth", {
|
|
36
|
+
enumerable: true,
|
|
37
|
+
configurable: true,
|
|
38
|
+
writable: true,
|
|
39
|
+
value: void 0
|
|
40
|
+
});
|
|
41
|
+
Object.defineProperty(this, "oversizedBehavior", {
|
|
42
|
+
enumerable: true,
|
|
43
|
+
configurable: true,
|
|
44
|
+
writable: true,
|
|
45
|
+
value: void 0
|
|
46
|
+
});
|
|
47
|
+
Object.defineProperty(this, "wrapLineCount", {
|
|
48
|
+
enumerable: true,
|
|
49
|
+
configurable: true,
|
|
50
|
+
writable: true,
|
|
51
|
+
value: 1
|
|
52
|
+
});
|
|
34
53
|
this.dataKey = config?.dataKey;
|
|
35
54
|
this.rotatedLabels = config?.rotatedLabels ?? false;
|
|
55
|
+
this.maxLabelWidth = config?.maxLabelWidth;
|
|
56
|
+
this.oversizedBehavior = config?.oversizedBehavior ?? 'truncate';
|
|
36
57
|
}
|
|
37
58
|
/**
|
|
38
59
|
* Returns the space required by the x-axis
|
|
@@ -41,7 +62,11 @@ export class XAxis {
|
|
|
41
62
|
// Height = tick padding + font size + some extra space for descenders
|
|
42
63
|
// Rotated labels need more vertical space (roughly 2.5x for -45deg rotation)
|
|
43
64
|
const baseHeight = this.tickPadding + this.fontSize + 5;
|
|
44
|
-
|
|
65
|
+
let height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
|
|
66
|
+
// Account for wrapped text height (multiply by estimated line count)
|
|
67
|
+
if (this.maxLabelWidth && this.oversizedBehavior === 'wrap' && this.wrapLineCount > 1) {
|
|
68
|
+
height += (this.wrapLineCount - 1) * this.fontSize * 1.2;
|
|
69
|
+
}
|
|
45
70
|
return {
|
|
46
71
|
width: 0, // X-axis spans full width
|
|
47
72
|
height,
|
|
@@ -51,6 +76,7 @@ export class XAxis {
|
|
|
51
76
|
render(svg, x, theme, yPosition) {
|
|
52
77
|
const axis = svg
|
|
53
78
|
.append('g')
|
|
79
|
+
.attr('class', 'x-axis')
|
|
54
80
|
.attr('transform', `translate(0,${yPosition})`)
|
|
55
81
|
.call(axisBottom(x)
|
|
56
82
|
.tickSizeOuter(0)
|
|
@@ -60,6 +86,10 @@ export class XAxis {
|
|
|
60
86
|
.attr('font-family', theme.axis.fontFamily)
|
|
61
87
|
.attr('font-weight', theme.axis.fontWeight || 'normal')
|
|
62
88
|
.attr('stroke', 'none');
|
|
89
|
+
// Apply label constraints before rotation
|
|
90
|
+
if (this.maxLabelWidth) {
|
|
91
|
+
this.applyLabelConstraints(axis, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal');
|
|
92
|
+
}
|
|
63
93
|
// Apply rotation to labels if enabled
|
|
64
94
|
if (this.rotatedLabels) {
|
|
65
95
|
axis.selectAll('text')
|
|
@@ -68,4 +98,60 @@ export class XAxis {
|
|
|
68
98
|
}
|
|
69
99
|
axis.selectAll('.domain').remove();
|
|
70
100
|
}
|
|
101
|
+
applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
|
|
102
|
+
if (!this.maxLabelWidth)
|
|
103
|
+
return;
|
|
104
|
+
const maxWidth = this.maxLabelWidth;
|
|
105
|
+
const behavior = this.oversizedBehavior;
|
|
106
|
+
axisGroup.selectAll('text').each((_d, i, nodes) => {
|
|
107
|
+
const textEl = nodes[i];
|
|
108
|
+
const originalText = textEl.textContent || '';
|
|
109
|
+
const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
|
|
110
|
+
if (textWidth <= maxWidth) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
switch (behavior) {
|
|
114
|
+
case 'truncate': {
|
|
115
|
+
const result = truncateText(originalText, maxWidth, fontSize, fontFamily, fontWeight, svg);
|
|
116
|
+
textEl.textContent = result.text;
|
|
117
|
+
if (result.truncated) {
|
|
118
|
+
this.addTitleTooltip(textEl, originalText);
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
case 'wrap': {
|
|
123
|
+
const lines = wrapText(originalText, maxWidth, fontSize, fontFamily, fontWeight, svg);
|
|
124
|
+
this.wrapTextElement(textEl, lines, originalText);
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
case 'hide': {
|
|
128
|
+
textEl.style.visibility = 'hidden';
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
wrapTextElement(textEl, lines, originalText) {
|
|
135
|
+
// Clear existing content
|
|
136
|
+
textEl.textContent = '';
|
|
137
|
+
const lineHeight = this.fontSize * 1.2;
|
|
138
|
+
// Update wrap line count for height calculation
|
|
139
|
+
this.wrapLineCount = Math.max(this.wrapLineCount, lines.length);
|
|
140
|
+
lines.forEach((line, i) => {
|
|
141
|
+
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
|
142
|
+
tspan.textContent = line;
|
|
143
|
+
tspan.setAttribute('x', textEl.getAttribute('x') || '0');
|
|
144
|
+
tspan.setAttribute('dy', i === 0 ? '0' : `${lineHeight}px`);
|
|
145
|
+
textEl.appendChild(tspan);
|
|
146
|
+
});
|
|
147
|
+
// Add tooltip with full text
|
|
148
|
+
if (lines.length > 1) {
|
|
149
|
+
this.addTitleTooltip(textEl, originalText);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
addTitleTooltip(textEl, text) {
|
|
153
|
+
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
154
|
+
title.textContent = text;
|
|
155
|
+
textEl.insertBefore(title, textEl.firstChild);
|
|
156
|
+
}
|
|
71
157
|
}
|
package/xy-chart.js
CHANGED
|
@@ -124,7 +124,7 @@ export class XYChart extends BaseChart {
|
|
|
124
124
|
// Render tooltip
|
|
125
125
|
if (this.tooltip && this.x && this.y) {
|
|
126
126
|
this.tooltip.initialize(this.theme);
|
|
127
|
-
this.tooltip.attachToArea(this.svg, sortedData, this.series, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this));
|
|
127
|
+
this.tooltip.attachToArea(this.svg, sortedData, this.series, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this), this.isHorizontalOrientation());
|
|
128
128
|
}
|
|
129
129
|
// Render legend if present
|
|
130
130
|
if (this.legend) {
|
package/y-axis.d.ts
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { type Selection } from 'd3';
|
|
2
|
-
import type { ChartTheme, YAxisConfig } from './types.js';
|
|
2
|
+
import type { ChartTheme, YAxisConfig, D3Scale } from './types.js';
|
|
3
3
|
import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
|
|
4
4
|
export declare class YAxis implements LayoutAwareComponent {
|
|
5
5
|
readonly type: "yAxis";
|
|
6
6
|
private readonly tickPadding;
|
|
7
|
+
private readonly fontSize;
|
|
7
8
|
private readonly maxLabelWidth;
|
|
8
9
|
private readonly tickFormat;
|
|
10
|
+
private readonly rotatedLabels;
|
|
11
|
+
private readonly oversizedBehavior;
|
|
9
12
|
constructor(config?: YAxisConfig);
|
|
10
13
|
/**
|
|
11
14
|
* Returns the space required by the y-axis
|
|
12
15
|
*/
|
|
13
16
|
getRequiredSpace(): ComponentSpace;
|
|
14
|
-
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, y:
|
|
17
|
+
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, y: D3Scale, theme: ChartTheme, xPosition: number): void;
|
|
18
|
+
private applyLabelConstraints;
|
|
19
|
+
private wrapTextElement;
|
|
20
|
+
private addTitleTooltip;
|
|
15
21
|
}
|
package/y-axis.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { axisLeft } from 'd3';
|
|
2
|
+
import { measureTextWidth, truncateText, wrapText } from './utils.js';
|
|
2
3
|
export class YAxis {
|
|
3
4
|
constructor(config) {
|
|
4
5
|
Object.defineProperty(this, "type", {
|
|
@@ -13,27 +14,52 @@ export class YAxis {
|
|
|
13
14
|
writable: true,
|
|
14
15
|
value: 10
|
|
15
16
|
});
|
|
17
|
+
Object.defineProperty(this, "fontSize", {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
configurable: true,
|
|
20
|
+
writable: true,
|
|
21
|
+
value: 14
|
|
22
|
+
});
|
|
16
23
|
Object.defineProperty(this, "maxLabelWidth", {
|
|
17
24
|
enumerable: true,
|
|
18
25
|
configurable: true,
|
|
19
26
|
writable: true,
|
|
20
|
-
value:
|
|
21
|
-
});
|
|
27
|
+
value: void 0
|
|
28
|
+
});
|
|
29
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
22
30
|
Object.defineProperty(this, "tickFormat", {
|
|
23
31
|
enumerable: true,
|
|
24
32
|
configurable: true,
|
|
25
33
|
writable: true,
|
|
26
34
|
value: void 0
|
|
27
35
|
});
|
|
36
|
+
Object.defineProperty(this, "rotatedLabels", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
value: void 0
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(this, "oversizedBehavior", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
value: void 0
|
|
47
|
+
});
|
|
28
48
|
this.tickFormat = config?.tickFormat ?? null;
|
|
49
|
+
this.rotatedLabels = config?.rotatedLabels ?? false;
|
|
50
|
+
this.maxLabelWidth = config?.maxLabelWidth ?? 40; // Default 40 for backward compatibility
|
|
51
|
+
this.oversizedBehavior = config?.oversizedBehavior ?? 'truncate';
|
|
29
52
|
}
|
|
30
53
|
/**
|
|
31
54
|
* Returns the space required by the y-axis
|
|
32
55
|
*/
|
|
33
56
|
getRequiredSpace() {
|
|
34
57
|
// Width = max label width + tick padding
|
|
58
|
+
// Rotated labels need less width (cos(45°) ≈ 0.7 of horizontal width)
|
|
59
|
+
const baseWidth = this.maxLabelWidth + this.tickPadding;
|
|
60
|
+
const width = this.rotatedLabels ? baseWidth * 0.7 : baseWidth;
|
|
35
61
|
return {
|
|
36
|
-
width
|
|
62
|
+
width,
|
|
37
63
|
height: 0, // Y-axis spans full height
|
|
38
64
|
position: 'left',
|
|
39
65
|
};
|
|
@@ -52,13 +78,83 @@ export class YAxis {
|
|
|
52
78
|
else {
|
|
53
79
|
axis.ticks(5);
|
|
54
80
|
}
|
|
55
|
-
svg
|
|
81
|
+
const axisGroup = svg
|
|
82
|
+
.append('g')
|
|
83
|
+
.attr('class', 'y-axis')
|
|
56
84
|
.attr('transform', `translate(${xPosition},0)`)
|
|
57
85
|
.call(axis)
|
|
58
86
|
.attr('font-size', theme.axis.fontSize)
|
|
59
87
|
.attr('font-family', theme.axis.fontFamily)
|
|
60
|
-
.attr('font-weight', theme.axis.fontWeight || 'normal')
|
|
61
|
-
|
|
62
|
-
|
|
88
|
+
.attr('font-weight', theme.axis.fontWeight || 'normal');
|
|
89
|
+
// Apply label constraints before rotation
|
|
90
|
+
this.applyLabelConstraints(axisGroup, svg.node(), theme.axis.fontSize, theme.axis.fontFamily, theme.axis.fontWeight || 'normal');
|
|
91
|
+
// Apply rotation to labels if enabled
|
|
92
|
+
if (this.rotatedLabels) {
|
|
93
|
+
axisGroup
|
|
94
|
+
.selectAll('text')
|
|
95
|
+
.style('text-anchor', 'end')
|
|
96
|
+
.attr('transform', 'rotate(-45)');
|
|
97
|
+
}
|
|
98
|
+
axisGroup.selectAll('.domain').remove();
|
|
99
|
+
}
|
|
100
|
+
applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
|
|
101
|
+
const maxWidth = this.maxLabelWidth;
|
|
102
|
+
const behavior = this.oversizedBehavior;
|
|
103
|
+
axisGroup.selectAll('text').each((_d, i, nodes) => {
|
|
104
|
+
const textEl = nodes[i];
|
|
105
|
+
const originalText = textEl.textContent || '';
|
|
106
|
+
const textWidth = measureTextWidth(originalText, fontSize, fontFamily, fontWeight, svg);
|
|
107
|
+
if (textWidth <= maxWidth) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
switch (behavior) {
|
|
111
|
+
case 'truncate': {
|
|
112
|
+
const result = truncateText(originalText, maxWidth, fontSize, fontFamily, fontWeight, svg);
|
|
113
|
+
textEl.textContent = result.text;
|
|
114
|
+
if (result.truncated) {
|
|
115
|
+
this.addTitleTooltip(textEl, originalText);
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case 'wrap': {
|
|
120
|
+
const lines = wrapText(originalText, maxWidth, fontSize, fontFamily, fontWeight, svg);
|
|
121
|
+
this.wrapTextElement(textEl, lines, originalText);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'hide': {
|
|
125
|
+
textEl.style.visibility = 'hidden';
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
wrapTextElement(textEl, lines, originalText) {
|
|
132
|
+
// Clear existing content
|
|
133
|
+
textEl.textContent = '';
|
|
134
|
+
const lineHeight = this.fontSize * 1.2;
|
|
135
|
+
// For Y-axis, center wrapped lines vertically around the tick position
|
|
136
|
+
const totalHeight = (lines.length - 1) * lineHeight;
|
|
137
|
+
const startOffset = -totalHeight / 2;
|
|
138
|
+
lines.forEach((line, i) => {
|
|
139
|
+
const tspan = document.createElementNS('http://www.w3.org/2000/svg', 'tspan');
|
|
140
|
+
tspan.textContent = line;
|
|
141
|
+
tspan.setAttribute('x', textEl.getAttribute('x') || '0');
|
|
142
|
+
if (i === 0) {
|
|
143
|
+
tspan.setAttribute('dy', `${startOffset}px`);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
tspan.setAttribute('dy', `${lineHeight}px`);
|
|
147
|
+
}
|
|
148
|
+
textEl.appendChild(tspan);
|
|
149
|
+
});
|
|
150
|
+
// Add tooltip with full text
|
|
151
|
+
if (lines.length > 1) {
|
|
152
|
+
this.addTitleTooltip(textEl, originalText);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
addTitleTooltip(textEl, text) {
|
|
156
|
+
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
|
157
|
+
title.textContent = text;
|
|
158
|
+
textEl.insertBefore(title, textEl.firstChild);
|
|
63
159
|
}
|
|
64
160
|
}
|