@internetstiftelsen/charts 0.0.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 +13 -0
- package/bar.js +137 -0
- package/base-chart.d.ts +72 -0
- package/base-chart.js +227 -0
- package/chart-interface.d.ts +11 -0
- package/chart-interface.js +1 -0
- package/grid.d.ts +10 -0
- package/grid.js +63 -0
- package/layout-manager.d.ts +38 -0
- package/layout-manager.js +123 -0
- package/legend.d.ts +23 -0
- package/legend.js +157 -0
- package/line.d.ts +11 -0
- package/line.js +82 -0
- package/package.json +57 -0
- package/theme.d.ts +2 -0
- package/theme.js +31 -0
- package/title.d.ts +18 -0
- package/title.js +88 -0
- package/tooltip.d.ts +15 -0
- package/tooltip.js +138 -0
- package/types.d.ts +83 -0
- package/types.js +4 -0
- package/utils.d.ts +2 -0
- package/utils.js +5 -0
- package/validation.d.ts +29 -0
- package/validation.js +75 -0
- package/x-axis.d.ts +16 -0
- package/x-axis.js +70 -0
- package/xy-chart.d.ts +18 -0
- package/xy-chart.js +272 -0
- package/y-axis.d.ts +15 -0
- package/y-axis.js +58 -0
package/tooltip.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { pointer, select } from 'd3';
|
|
2
|
+
import { getSeriesColor } from './types';
|
|
3
|
+
export class Tooltip {
|
|
4
|
+
constructor(config) {
|
|
5
|
+
Object.defineProperty(this, "type", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
writable: true,
|
|
9
|
+
value: 'tooltip'
|
|
10
|
+
});
|
|
11
|
+
Object.defineProperty(this, "formatter", {
|
|
12
|
+
enumerable: true,
|
|
13
|
+
configurable: true,
|
|
14
|
+
writable: true,
|
|
15
|
+
value: void 0
|
|
16
|
+
});
|
|
17
|
+
Object.defineProperty(this, "tooltipDiv", {
|
|
18
|
+
enumerable: true,
|
|
19
|
+
configurable: true,
|
|
20
|
+
writable: true,
|
|
21
|
+
value: null
|
|
22
|
+
});
|
|
23
|
+
this.formatter = config?.formatter;
|
|
24
|
+
}
|
|
25
|
+
initialize(theme) {
|
|
26
|
+
this.tooltipDiv = select('body')
|
|
27
|
+
.append('div')
|
|
28
|
+
.attr('class', 'chart-tooltip')
|
|
29
|
+
.style('position', 'absolute')
|
|
30
|
+
.style('visibility', 'hidden')
|
|
31
|
+
.style('background-color', 'white')
|
|
32
|
+
.style('border', '1px solid #ddd')
|
|
33
|
+
.style('border-radius', '4px')
|
|
34
|
+
.style('padding', '8px')
|
|
35
|
+
.style('box-shadow', '0 2px 4px rgba(0,0,0,0.1)')
|
|
36
|
+
.style('font-family', theme.axis.fontFamily)
|
|
37
|
+
.style('font-size', '12px')
|
|
38
|
+
.style('pointer-events', 'none')
|
|
39
|
+
.style('z-index', '1000');
|
|
40
|
+
}
|
|
41
|
+
attachToArea(svg, data, series, xKey, x, y, _theme, plotArea, parseValue) {
|
|
42
|
+
if (!this.tooltipDiv)
|
|
43
|
+
return;
|
|
44
|
+
const tooltip = this.tooltipDiv;
|
|
45
|
+
const formatter = this.formatter;
|
|
46
|
+
// Helper to get x position for any scale type
|
|
47
|
+
const getXPosition = (dataPoint) => {
|
|
48
|
+
const xValue = dataPoint[xKey];
|
|
49
|
+
const scaled = x(xValue instanceof Date
|
|
50
|
+
? xValue
|
|
51
|
+
: typeof xValue === 'string'
|
|
52
|
+
? xValue
|
|
53
|
+
: new Date(xValue));
|
|
54
|
+
return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
|
|
55
|
+
};
|
|
56
|
+
// Create overlay rect for mouse tracking using plot area bounds
|
|
57
|
+
const overlay = svg
|
|
58
|
+
.append('rect')
|
|
59
|
+
.attr('class', 'tooltip-overlay')
|
|
60
|
+
.attr('x', plotArea.left)
|
|
61
|
+
.attr('y', plotArea.top)
|
|
62
|
+
.attr('width', plotArea.width)
|
|
63
|
+
.attr('height', plotArea.height)
|
|
64
|
+
.style('fill', 'none')
|
|
65
|
+
.style('pointer-events', 'all');
|
|
66
|
+
// Create focus circles for each series
|
|
67
|
+
const focusCircles = series.map((s) => {
|
|
68
|
+
return svg
|
|
69
|
+
.append('circle')
|
|
70
|
+
.attr('class', `focus-circle-${s.dataKey.replace(/\s+/g, '-')}`)
|
|
71
|
+
.attr('r', 5)
|
|
72
|
+
.attr('fill', getSeriesColor(s))
|
|
73
|
+
.attr('stroke', 'white')
|
|
74
|
+
.attr('stroke-width', 2)
|
|
75
|
+
.style('opacity', 0)
|
|
76
|
+
.style('pointer-events', 'none');
|
|
77
|
+
});
|
|
78
|
+
overlay
|
|
79
|
+
.on('mousemove', (event) => {
|
|
80
|
+
const [mouseX] = pointer(event, svg.node());
|
|
81
|
+
// Find closest x value
|
|
82
|
+
const xPositions = data.map((d) => getXPosition(d));
|
|
83
|
+
let closestIndex = 0;
|
|
84
|
+
let minDistance = Math.abs(mouseX - xPositions[0]);
|
|
85
|
+
for (let i = 1; i < xPositions.length; i++) {
|
|
86
|
+
const distance = Math.abs(mouseX - xPositions[i]);
|
|
87
|
+
if (distance < minDistance) {
|
|
88
|
+
minDistance = distance;
|
|
89
|
+
closestIndex = i;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const dataPoint = data[closestIndex];
|
|
93
|
+
const xPos = xPositions[closestIndex];
|
|
94
|
+
// Update focus circles
|
|
95
|
+
series.forEach((s, i) => {
|
|
96
|
+
const yValue = parseValue(dataPoint[s.dataKey]);
|
|
97
|
+
const yPos = y(yValue);
|
|
98
|
+
focusCircles[i]
|
|
99
|
+
.attr('cx', xPos)
|
|
100
|
+
.attr('cy', yPos)
|
|
101
|
+
.style('opacity', 1);
|
|
102
|
+
});
|
|
103
|
+
// Build tooltip content
|
|
104
|
+
let content = `<strong>${dataPoint[xKey]}</strong><br/>`;
|
|
105
|
+
series.forEach((s) => {
|
|
106
|
+
const value = dataPoint[s.dataKey];
|
|
107
|
+
if (formatter) {
|
|
108
|
+
content +=
|
|
109
|
+
formatter(s.dataKey, value, dataPoint) + '<br/>';
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
content += `${s.dataKey}: ${value}<br/>`;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
// Position tooltip relative to the data point
|
|
116
|
+
const svgRect = svg.node().getBoundingClientRect();
|
|
117
|
+
const tooltipX = svgRect.left + xPos + 10;
|
|
118
|
+
const tooltipY = svgRect.top +
|
|
119
|
+
y(parseValue(dataPoint[series[0].dataKey])) -
|
|
120
|
+
10;
|
|
121
|
+
tooltip
|
|
122
|
+
.style('visibility', 'visible')
|
|
123
|
+
.html(content)
|
|
124
|
+
.style('left', `${tooltipX}px`)
|
|
125
|
+
.style('top', `${tooltipY}px`);
|
|
126
|
+
})
|
|
127
|
+
.on('mouseout', () => {
|
|
128
|
+
tooltip.style('visibility', 'hidden');
|
|
129
|
+
focusCircles.forEach((circle) => circle.style('opacity', 0));
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
cleanup() {
|
|
133
|
+
if (this.tooltipDiv) {
|
|
134
|
+
this.tooltipDiv.remove();
|
|
135
|
+
this.tooltipDiv = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
package/types.d.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export type DataItem = {
|
|
2
|
+
[key: string]: any;
|
|
3
|
+
};
|
|
4
|
+
export type ColorPalette = string[];
|
|
5
|
+
export type ChartTheme = {
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
margins: {
|
|
9
|
+
top: number;
|
|
10
|
+
right: number;
|
|
11
|
+
bottom: number;
|
|
12
|
+
left: number;
|
|
13
|
+
};
|
|
14
|
+
gridColor: string;
|
|
15
|
+
colorPalette: ColorPalette;
|
|
16
|
+
axis: {
|
|
17
|
+
fontFamily: string;
|
|
18
|
+
fontSize: string;
|
|
19
|
+
};
|
|
20
|
+
legend: {
|
|
21
|
+
boxSize: number;
|
|
22
|
+
uncheckedColor: string;
|
|
23
|
+
fontSize: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
export type LineConfig = {
|
|
27
|
+
dataKey: string;
|
|
28
|
+
stroke?: string;
|
|
29
|
+
strokeWidth?: number;
|
|
30
|
+
};
|
|
31
|
+
export type BarConfig = {
|
|
32
|
+
dataKey: string;
|
|
33
|
+
fill?: string;
|
|
34
|
+
orientation?: 'vertical' | 'horizontal';
|
|
35
|
+
};
|
|
36
|
+
export declare function getSeriesColor(series: {
|
|
37
|
+
stroke?: string;
|
|
38
|
+
fill?: string;
|
|
39
|
+
}): string;
|
|
40
|
+
export type XAxisConfig = {
|
|
41
|
+
dataKey?: string;
|
|
42
|
+
rotatedLabels?: boolean;
|
|
43
|
+
};
|
|
44
|
+
export type YAxisConfig = {
|
|
45
|
+
tickFormat?: string | null;
|
|
46
|
+
};
|
|
47
|
+
export type GridConfig = {
|
|
48
|
+
horizontal?: boolean;
|
|
49
|
+
vertical?: boolean;
|
|
50
|
+
};
|
|
51
|
+
export type TooltipConfig = {
|
|
52
|
+
formatter?: (dataKey: string, value: any, data: DataItem) => string;
|
|
53
|
+
};
|
|
54
|
+
export type LegendConfig = {
|
|
55
|
+
position?: 'bottom';
|
|
56
|
+
marginTop?: number;
|
|
57
|
+
marginBottom?: number;
|
|
58
|
+
};
|
|
59
|
+
export type TitleConfig = {
|
|
60
|
+
text: string;
|
|
61
|
+
fontSize?: number;
|
|
62
|
+
fontWeight?: string;
|
|
63
|
+
align?: 'left' | 'center' | 'right';
|
|
64
|
+
marginTop?: number;
|
|
65
|
+
marginBottom?: number;
|
|
66
|
+
};
|
|
67
|
+
export type ChartStyle = {
|
|
68
|
+
maxHeight?: string;
|
|
69
|
+
aspectRatio?: number;
|
|
70
|
+
};
|
|
71
|
+
export type ScaleType = 'band' | 'linear' | 'time' | 'log';
|
|
72
|
+
export type ScaleConfig = {
|
|
73
|
+
type: ScaleType;
|
|
74
|
+
domain?: any[];
|
|
75
|
+
range?: any[];
|
|
76
|
+
padding?: number;
|
|
77
|
+
nice?: boolean;
|
|
78
|
+
};
|
|
79
|
+
export type AxisScaleConfig = {
|
|
80
|
+
x?: Partial<ScaleConfig>;
|
|
81
|
+
y?: Partial<ScaleConfig>;
|
|
82
|
+
};
|
|
83
|
+
export type Orientation = 'vertical' | 'horizontal';
|
package/types.js
ADDED
package/utils.d.ts
ADDED
package/utils.js
ADDED
package/validation.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DataItem } from './types';
|
|
2
|
+
export declare class ChartValidationError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Validates chart data and configuration
|
|
7
|
+
*/
|
|
8
|
+
export declare class ChartValidator {
|
|
9
|
+
/**
|
|
10
|
+
* Validates that data is an array and not empty
|
|
11
|
+
*/
|
|
12
|
+
static validateData(data: any): asserts data is DataItem[];
|
|
13
|
+
/**
|
|
14
|
+
* Validates that the specified dataKey exists in all data items
|
|
15
|
+
*/
|
|
16
|
+
static validateDataKey(data: DataItem[], dataKey: string, componentName: string): void;
|
|
17
|
+
/**
|
|
18
|
+
* Validates that data contains at least one valid numeric value for the specified key
|
|
19
|
+
*/
|
|
20
|
+
static validateNumericData(data: DataItem[], dataKey: string, componentName: string): void;
|
|
21
|
+
/**
|
|
22
|
+
* Validates scale configuration
|
|
23
|
+
*/
|
|
24
|
+
static validateScaleConfig(scaleType: string, domain: any[] | undefined): void;
|
|
25
|
+
/**
|
|
26
|
+
* Warns about potential issues without throwing errors
|
|
27
|
+
*/
|
|
28
|
+
static warn(message: string): void;
|
|
29
|
+
}
|
package/validation.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class ChartValidationError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = 'ChartValidationError';
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Validates chart data and configuration
|
|
9
|
+
*/
|
|
10
|
+
export class ChartValidator {
|
|
11
|
+
/**
|
|
12
|
+
* Validates that data is an array and not empty
|
|
13
|
+
*/
|
|
14
|
+
static validateData(data) {
|
|
15
|
+
if (!Array.isArray(data)) {
|
|
16
|
+
throw new ChartValidationError(`Data must be an array, received ${typeof data}`);
|
|
17
|
+
}
|
|
18
|
+
if (data.length === 0) {
|
|
19
|
+
throw new ChartValidationError('Data array cannot be empty');
|
|
20
|
+
}
|
|
21
|
+
// Validate that all items are objects
|
|
22
|
+
data.forEach((item, index) => {
|
|
23
|
+
if (typeof item !== 'object' || item === null) {
|
|
24
|
+
throw new ChartValidationError(`Data item at index ${index} must be an object, received ${typeof item}`);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Validates that the specified dataKey exists in all data items
|
|
30
|
+
*/
|
|
31
|
+
static validateDataKey(data, dataKey, componentName) {
|
|
32
|
+
const missingKeys = [];
|
|
33
|
+
data.forEach((item, index) => {
|
|
34
|
+
if (!(dataKey in item)) {
|
|
35
|
+
missingKeys.push(index);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
if (missingKeys.length > 0) {
|
|
39
|
+
const indices = missingKeys.slice(0, 3).join(', ');
|
|
40
|
+
const more = missingKeys.length > 3
|
|
41
|
+
? ` and ${missingKeys.length - 3} more`
|
|
42
|
+
: '';
|
|
43
|
+
throw new ChartValidationError(`${componentName}: dataKey "${dataKey}" not found in data items at indices: ${indices}${more}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Validates that data contains at least one valid numeric value for the specified key
|
|
48
|
+
*/
|
|
49
|
+
static validateNumericData(data, dataKey, componentName) {
|
|
50
|
+
const hasNumericValue = data.some((item) => {
|
|
51
|
+
const value = item[dataKey];
|
|
52
|
+
return (typeof value === 'number' || !isNaN(parseFloat(String(value))));
|
|
53
|
+
});
|
|
54
|
+
if (!hasNumericValue) {
|
|
55
|
+
throw new ChartValidationError(`${componentName}: No valid numeric values found for dataKey "${dataKey}"`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Validates scale configuration
|
|
60
|
+
*/
|
|
61
|
+
static validateScaleConfig(scaleType, domain) {
|
|
62
|
+
if (scaleType === 'log' && domain) {
|
|
63
|
+
const hasZeroOrNegative = domain.some((v) => typeof v === 'number' && v <= 0);
|
|
64
|
+
if (hasZeroOrNegative) {
|
|
65
|
+
throw new ChartValidationError('Logarithmic scale cannot include zero or negative values in domain');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Warns about potential issues without throwing errors
|
|
71
|
+
*/
|
|
72
|
+
static warn(message) {
|
|
73
|
+
console.warn(`[Chart Warning] ${message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
package/x-axis.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Selection } from 'd3';
|
|
2
|
+
import type { XAxisConfig, ChartTheme } from './types';
|
|
3
|
+
import type { LayoutAwareComponent, ComponentSpace } from '@/lib/chart-interface';
|
|
4
|
+
export declare class XAxis implements LayoutAwareComponent {
|
|
5
|
+
readonly type: "xAxis";
|
|
6
|
+
readonly dataKey?: string;
|
|
7
|
+
private readonly rotatedLabels;
|
|
8
|
+
private readonly tickPadding;
|
|
9
|
+
private readonly fontSize;
|
|
10
|
+
constructor(config?: XAxisConfig);
|
|
11
|
+
/**
|
|
12
|
+
* Returns the space required by the x-axis
|
|
13
|
+
*/
|
|
14
|
+
getRequiredSpace(): ComponentSpace;
|
|
15
|
+
render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: any, theme: ChartTheme, yPosition: number): void;
|
|
16
|
+
}
|
package/x-axis.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { axisBottom } from 'd3';
|
|
2
|
+
export class XAxis {
|
|
3
|
+
constructor(config) {
|
|
4
|
+
Object.defineProperty(this, "type", {
|
|
5
|
+
enumerable: true,
|
|
6
|
+
configurable: true,
|
|
7
|
+
writable: true,
|
|
8
|
+
value: 'xAxis'
|
|
9
|
+
});
|
|
10
|
+
Object.defineProperty(this, "dataKey", {
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
writable: true,
|
|
14
|
+
value: void 0
|
|
15
|
+
});
|
|
16
|
+
Object.defineProperty(this, "rotatedLabels", {
|
|
17
|
+
enumerable: true,
|
|
18
|
+
configurable: true,
|
|
19
|
+
writable: true,
|
|
20
|
+
value: void 0
|
|
21
|
+
});
|
|
22
|
+
Object.defineProperty(this, "tickPadding", {
|
|
23
|
+
enumerable: true,
|
|
24
|
+
configurable: true,
|
|
25
|
+
writable: true,
|
|
26
|
+
value: 10
|
|
27
|
+
});
|
|
28
|
+
Object.defineProperty(this, "fontSize", {
|
|
29
|
+
enumerable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
writable: true,
|
|
32
|
+
value: 14
|
|
33
|
+
});
|
|
34
|
+
this.dataKey = config?.dataKey;
|
|
35
|
+
this.rotatedLabels = config?.rotatedLabels ?? false;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Returns the space required by the x-axis
|
|
39
|
+
*/
|
|
40
|
+
getRequiredSpace() {
|
|
41
|
+
// Height = tick padding + font size + some extra space for descenders
|
|
42
|
+
// Rotated labels need more vertical space (roughly 2.5x for -45deg rotation)
|
|
43
|
+
const baseHeight = this.tickPadding + this.fontSize + 5;
|
|
44
|
+
const height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
|
|
45
|
+
return {
|
|
46
|
+
width: 0, // X-axis spans full width
|
|
47
|
+
height,
|
|
48
|
+
position: 'bottom',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
render(svg, x, theme, yPosition) {
|
|
52
|
+
const axis = svg
|
|
53
|
+
.append('g')
|
|
54
|
+
.attr('transform', `translate(0,${yPosition})`)
|
|
55
|
+
.call(axisBottom(x)
|
|
56
|
+
.tickSizeOuter(0)
|
|
57
|
+
.tickSize(0)
|
|
58
|
+
.tickPadding(this.tickPadding))
|
|
59
|
+
.attr('font-size', theme.axis.fontSize)
|
|
60
|
+
.attr('font-family', theme.axis.fontFamily)
|
|
61
|
+
.attr('stroke', 'none');
|
|
62
|
+
// Apply rotation to labels if enabled
|
|
63
|
+
if (this.rotatedLabels) {
|
|
64
|
+
axis.selectAll('text')
|
|
65
|
+
.style('text-anchor', 'end')
|
|
66
|
+
.attr('transform', 'rotate(-45)');
|
|
67
|
+
}
|
|
68
|
+
axis.selectAll('.domain').remove();
|
|
69
|
+
}
|
|
70
|
+
}
|
package/xy-chart.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BaseChart, type BaseChartConfig } from './base-chart';
|
|
2
|
+
import type { ChartComponent } from '@/lib/chart-interface';
|
|
3
|
+
export type XYChartConfig = BaseChartConfig;
|
|
4
|
+
export declare class XYChart extends BaseChart {
|
|
5
|
+
private readonly series;
|
|
6
|
+
private sortedDataCache;
|
|
7
|
+
private xKeyCache;
|
|
8
|
+
constructor(config: XYChartConfig);
|
|
9
|
+
addChild(component: ChartComponent): this;
|
|
10
|
+
private rerender;
|
|
11
|
+
protected renderChart(): void;
|
|
12
|
+
private getXKey;
|
|
13
|
+
private getSortedData;
|
|
14
|
+
private setupScales;
|
|
15
|
+
private isHorizontalOrientation;
|
|
16
|
+
private createScale;
|
|
17
|
+
private renderSeries;
|
|
18
|
+
}
|