@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.
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Layout manager that calculates component positions and plot area bounds
3
+ * Follows D3's margin convention pattern
4
+ */
5
+ export class LayoutManager {
6
+ constructor(theme) {
7
+ Object.defineProperty(this, "theme", {
8
+ enumerable: true,
9
+ configurable: true,
10
+ writable: true,
11
+ value: void 0
12
+ });
13
+ Object.defineProperty(this, "plotBounds", {
14
+ enumerable: true,
15
+ configurable: true,
16
+ writable: true,
17
+ value: null
18
+ });
19
+ Object.defineProperty(this, "componentPositions", {
20
+ enumerable: true,
21
+ configurable: true,
22
+ writable: true,
23
+ value: new Map()
24
+ });
25
+ this.theme = theme;
26
+ }
27
+ /**
28
+ * Calculate layout based on registered components
29
+ * Returns the plot area bounds
30
+ */
31
+ calculateLayout(components) {
32
+ // Start with base margins from theme
33
+ let marginTop = this.theme.margins.top;
34
+ let marginRight = this.theme.margins.right;
35
+ let marginBottom = this.theme.margins.bottom;
36
+ let marginLeft = this.theme.margins.left;
37
+ // Accumulate space requirements from components
38
+ for (const component of components) {
39
+ const space = component.getRequiredSpace();
40
+ switch (space.position) {
41
+ case 'top':
42
+ marginTop += space.height;
43
+ break;
44
+ case 'bottom':
45
+ marginBottom += space.height;
46
+ break;
47
+ case 'left':
48
+ marginLeft += space.width;
49
+ break;
50
+ case 'right':
51
+ marginRight += space.width;
52
+ break;
53
+ }
54
+ }
55
+ // Calculate plot area bounds
56
+ this.plotBounds = {
57
+ left: marginLeft,
58
+ right: this.theme.width - marginRight,
59
+ top: marginTop,
60
+ bottom: this.theme.height - marginBottom,
61
+ width: this.theme.width - marginLeft - marginRight,
62
+ height: this.theme.height - marginTop - marginBottom,
63
+ };
64
+ // Calculate component positions
65
+ this.calculateComponentPositions(components);
66
+ return this.plotBounds;
67
+ }
68
+ /**
69
+ * Get the position for a specific component
70
+ */
71
+ getComponentPosition(component) {
72
+ const position = this.componentPositions.get(component);
73
+ if (!position) {
74
+ throw new Error('Component not found in layout');
75
+ }
76
+ return position;
77
+ }
78
+ /**
79
+ * Calculate positions for all components based on their space requirements
80
+ * Components are positioned in registration order, stacking outward from the plot area
81
+ */
82
+ calculateComponentPositions(components) {
83
+ if (!this.plotBounds)
84
+ return;
85
+ // Track cumulative offsets from plot area edges
86
+ let topOffset = 0;
87
+ let bottomOffset = 0;
88
+ let leftOffset = 0;
89
+ let rightOffset = 0;
90
+ for (const component of components) {
91
+ const space = component.getRequiredSpace();
92
+ let x = 0;
93
+ let y = 0;
94
+ switch (space.position) {
95
+ case 'top':
96
+ // Position above plot area, stacking upward
97
+ x = 0;
98
+ y = this.plotBounds.top - topOffset - space.height;
99
+ topOffset += space.height;
100
+ break;
101
+ case 'bottom':
102
+ // Position below plot area, stacking downward
103
+ x = 0;
104
+ y = this.plotBounds.bottom + bottomOffset;
105
+ bottomOffset += space.height;
106
+ break;
107
+ case 'left':
108
+ // Position left of plot area, stacking leftward
109
+ x = this.plotBounds.left - leftOffset - space.width;
110
+ y = 0;
111
+ leftOffset += space.width;
112
+ break;
113
+ case 'right':
114
+ // Position right of plot area, stacking rightward
115
+ x = this.plotBounds.right + rightOffset;
116
+ y = 0;
117
+ rightOffset += space.width;
118
+ break;
119
+ }
120
+ this.componentPositions.set(component, { x, y });
121
+ }
122
+ }
123
+ }
package/legend.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { type Selection } from 'd3';
2
+ import type { LegendConfig, ChartTheme } from './types';
3
+ import type { Line } from './line';
4
+ import type { Bar } from './bar';
5
+ import type { LayoutAwareComponent, ComponentSpace } from '@/lib/chart-interface';
6
+ export declare class Legend implements LayoutAwareComponent {
7
+ readonly type: "legend";
8
+ readonly position: LegendConfig['position'];
9
+ private readonly marginTop;
10
+ private readonly marginBottom;
11
+ private readonly itemHeight;
12
+ private visibilityState;
13
+ private onToggleCallback?;
14
+ constructor(config?: LegendConfig);
15
+ setToggleCallback(callback: () => void): void;
16
+ isSeriesVisible(dataKey: string): boolean;
17
+ private getCheckmarkPath;
18
+ /**
19
+ * Returns the space required by the legend
20
+ */
21
+ getRequiredSpace(): ComponentSpace;
22
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, series: (Line | Bar)[], theme: ChartTheme, width: number, _x?: number, y?: number): void;
23
+ }
package/legend.js ADDED
@@ -0,0 +1,157 @@
1
+ import {} from 'd3';
2
+ import { getSeriesColor } from './types';
3
+ export class Legend {
4
+ constructor(config) {
5
+ Object.defineProperty(this, "type", {
6
+ enumerable: true,
7
+ configurable: true,
8
+ writable: true,
9
+ value: 'legend'
10
+ });
11
+ Object.defineProperty(this, "position", {
12
+ enumerable: true,
13
+ configurable: true,
14
+ writable: true,
15
+ value: void 0
16
+ });
17
+ Object.defineProperty(this, "marginTop", {
18
+ enumerable: true,
19
+ configurable: true,
20
+ writable: true,
21
+ value: void 0
22
+ });
23
+ Object.defineProperty(this, "marginBottom", {
24
+ enumerable: true,
25
+ configurable: true,
26
+ writable: true,
27
+ value: void 0
28
+ });
29
+ Object.defineProperty(this, "itemHeight", {
30
+ enumerable: true,
31
+ configurable: true,
32
+ writable: true,
33
+ value: 15
34
+ });
35
+ Object.defineProperty(this, "visibilityState", {
36
+ enumerable: true,
37
+ configurable: true,
38
+ writable: true,
39
+ value: new Map()
40
+ });
41
+ Object.defineProperty(this, "onToggleCallback", {
42
+ enumerable: true,
43
+ configurable: true,
44
+ writable: true,
45
+ value: void 0
46
+ });
47
+ this.position = config?.position || 'bottom';
48
+ this.marginTop = config?.marginTop ?? 20;
49
+ this.marginBottom = config?.marginBottom ?? 10;
50
+ }
51
+ setToggleCallback(callback) {
52
+ this.onToggleCallback = callback;
53
+ }
54
+ isSeriesVisible(dataKey) {
55
+ return this.visibilityState.get(dataKey) ?? true;
56
+ }
57
+ getCheckmarkPath(size) {
58
+ const scale = size / 24;
59
+ return `M ${4 * scale} ${12 * scale} L ${9 * scale} ${17 * scale} L ${20 * scale} ${6 * scale}`;
60
+ }
61
+ /**
62
+ * Returns the space required by the legend
63
+ */
64
+ getRequiredSpace() {
65
+ return {
66
+ width: 0, // Legend spans full width
67
+ height: this.marginTop + this.itemHeight + this.marginBottom,
68
+ position: 'bottom',
69
+ };
70
+ }
71
+ render(svg, series, theme, width, _x = 0, y = 0) {
72
+ const boxSize = theme.legend.boxSize;
73
+ const gapBetweenBoxAndText = 8;
74
+ const itemSpacing = 20; // Space between legend items
75
+ const legendItems = series.map((s) => ({
76
+ label: s.dataKey,
77
+ color: getSeriesColor(s),
78
+ dataKey: s.dataKey,
79
+ }));
80
+ legendItems.forEach((item) => {
81
+ if (!this.visibilityState.has(item.dataKey)) {
82
+ this.visibilityState.set(item.dataKey, true);
83
+ }
84
+ });
85
+ // Create temporary text elements to measure widths
86
+ const tempSvg = svg.append('g').style('visibility', 'hidden');
87
+ const itemWidths = legendItems.map((item) => {
88
+ const textElem = tempSvg
89
+ .append('text')
90
+ .attr('font-size', `${theme.legend.fontSize}px`)
91
+ .attr('font-family', theme.axis.fontFamily)
92
+ .text(item.label);
93
+ const textWidth = textElem.node()?.getBBox().width || 0;
94
+ textElem.remove();
95
+ return boxSize + gapBetweenBoxAndText + textWidth;
96
+ });
97
+ tempSvg.remove();
98
+ // Calculate positions for each item
99
+ const itemPositions = [];
100
+ let currentX = 0;
101
+ itemWidths.forEach((itemWidth) => {
102
+ itemPositions.push(currentX);
103
+ currentX += itemWidth + itemSpacing;
104
+ });
105
+ const totalLegendWidth = currentX - itemSpacing;
106
+ const legendX = (width - totalLegendWidth) / 2;
107
+ const legendY = y + this.marginTop;
108
+ const legend = svg
109
+ .append('g')
110
+ .attr('class', 'legend')
111
+ .attr('transform', `translate(${legendX}, ${legendY})`);
112
+ const legendGroups = legend
113
+ .selectAll('g')
114
+ .data(legendItems)
115
+ .join('g')
116
+ .attr('transform', (_d, i) => `translate(${itemPositions[i]}, 0)`)
117
+ .style('cursor', 'pointer')
118
+ .on('click', (_event, d) => {
119
+ const currentState = this.visibilityState.get(d.dataKey) ?? true;
120
+ this.visibilityState.set(d.dataKey, !currentState);
121
+ if (this.onToggleCallback) {
122
+ this.onToggleCallback();
123
+ }
124
+ });
125
+ // Add checkbox rect
126
+ legendGroups
127
+ .append('rect')
128
+ .attr('width', boxSize)
129
+ .attr('height', boxSize)
130
+ .attr('fill', (d) => {
131
+ const isVisible = this.visibilityState.get(d.dataKey) ?? true;
132
+ return isVisible ? d.color : theme.legend.uncheckedColor;
133
+ })
134
+ .attr('rx', 2);
135
+ // Add checkmark when visible
136
+ legendGroups
137
+ .append('path')
138
+ .attr('d', this.getCheckmarkPath(boxSize))
139
+ .attr('fill', 'none')
140
+ .attr('stroke', '#000')
141
+ .attr('stroke-width', 2)
142
+ .attr('stroke-linecap', 'round')
143
+ .attr('stroke-linejoin', 'round')
144
+ .style('display', (d) => {
145
+ const isVisible = this.visibilityState.get(d.dataKey) ?? true;
146
+ return isVisible ? 'block' : 'none';
147
+ });
148
+ // Add label text
149
+ legendGroups
150
+ .append('text')
151
+ .attr('x', boxSize + gapBetweenBoxAndText)
152
+ .attr('y', boxSize / 2 + 4)
153
+ .attr('font-size', `${theme.legend.fontSize}px`)
154
+ .attr('font-family', theme.axis.fontFamily)
155
+ .text((d) => d.label);
156
+ }
157
+ }
package/line.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { type Selection } from 'd3';
2
+ import type { LineConfig, DataItem, ScaleType } from './types';
3
+ import type { ChartComponent } from '@/lib/chart-interface';
4
+ export declare class Line implements ChartComponent {
5
+ readonly type: "line";
6
+ readonly dataKey: string;
7
+ readonly stroke: string;
8
+ readonly strokeWidth: number;
9
+ constructor(config: LineConfig);
10
+ render(plotGroup: Selection<SVGGElement, undefined, null, undefined>, data: DataItem[], xKey: string, x: any, y: any, parseValue: (value: any) => number, xScaleType?: ScaleType): void;
11
+ }
package/line.js ADDED
@@ -0,0 +1,82 @@
1
+ import { line } from 'd3';
2
+ export class Line {
3
+ constructor(config) {
4
+ Object.defineProperty(this, "type", {
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true,
8
+ value: 'line'
9
+ });
10
+ Object.defineProperty(this, "dataKey", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: void 0
15
+ });
16
+ Object.defineProperty(this, "stroke", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "strokeWidth", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ this.dataKey = config.dataKey;
29
+ this.stroke = config.stroke || '#8884d8';
30
+ this.strokeWidth = config.strokeWidth || 2;
31
+ }
32
+ render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band') {
33
+ const getXPosition = (d) => {
34
+ const xValue = d[xKey];
35
+ // Handle different scale types appropriately
36
+ let scaledValue;
37
+ switch (xScaleType) {
38
+ case 'band':
39
+ // Band scale - use string or value as-is
40
+ scaledValue = xValue;
41
+ break;
42
+ case 'time':
43
+ // Time scale - convert to Date
44
+ scaledValue =
45
+ xValue instanceof Date ? xValue : new Date(xValue);
46
+ break;
47
+ case 'linear':
48
+ case 'log':
49
+ // Linear/log scale - convert to number
50
+ scaledValue =
51
+ typeof xValue === 'number' ? xValue : Number(xValue);
52
+ break;
53
+ }
54
+ const scaled = x(scaledValue);
55
+ // Handle band scales with bandwidth
56
+ return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
57
+ };
58
+ const lineGenerator = line()
59
+ .x(getXPosition)
60
+ .y((d) => y(parseValue(d[this.dataKey])) || 0);
61
+ // Add line path
62
+ plotGroup
63
+ .append('path')
64
+ .datum(data)
65
+ .attr('fill', 'none')
66
+ .attr('stroke', this.stroke)
67
+ .attr('stroke-width', this.strokeWidth)
68
+ .attr('d', lineGenerator);
69
+ // Add data point circles
70
+ plotGroup
71
+ .selectAll(`.circle-${this.dataKey.replace(/\s+/g, '-')}`)
72
+ .data(data)
73
+ .join('circle')
74
+ .attr('class', `circle-${this.dataKey.replace(/\s+/g, '-')}`)
75
+ .attr('cx', getXPosition)
76
+ .attr('cy', (d) => y(parseValue(d[this.dataKey])) || 0)
77
+ .attr('r', 4)
78
+ .attr('fill', this.stroke)
79
+ .attr('stroke', 'white')
80
+ .attr('stroke-width', 2);
81
+ }
82
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "version": "0.0.1",
3
+ "name": "@internetstiftelsen/charts",
4
+ "type": "module",
5
+ "exports": {
6
+ "./*": {
7
+ "types": "./*.d.ts",
8
+ "import": "./*.js"
9
+ }
10
+ },
11
+ "files": [
12
+ "*.js",
13
+ "*.d.ts"
14
+ ],
15
+ "scripts": {
16
+ "dev": "vite",
17
+ "build": "tsc -b && vite build",
18
+ "lint": "eslint .",
19
+ "preview": "vite preview",
20
+ "build:lib": "tsc --project tsconfig.lib.json && tsc-alias --project tsconfig.lib.json",
21
+ "prepub": "rm -rf dist && npm run build:lib && cp package.json dist",
22
+ "pub": "npm run prepub && cd dist && npm publish --access public"
23
+ },
24
+ "dependencies": {
25
+ "@radix-ui/react-label": "^2.1.8",
26
+ "@radix-ui/react-select": "^2.2.6",
27
+ "@radix-ui/react-switch": "^1.2.6",
28
+ "@radix-ui/react-tabs": "^1.1.13",
29
+ "@tailwindcss/vite": "^4.1.16",
30
+ "@types/d3": "^7.4.3",
31
+ "class-variance-authority": "^0.7.1",
32
+ "clsx": "^2.1.1",
33
+ "d3": "^7.9.0",
34
+ "lucide-react": "^0.548.0",
35
+ "react": "^19.2.0",
36
+ "react-dom": "^19.2.0",
37
+ "tailwind-merge": "^3.3.1",
38
+ "tailwindcss": "^4.1.16"
39
+ },
40
+ "devDependencies": {
41
+ "@eslint/js": "^9.38.0",
42
+ "@types/node": "^24.9.2",
43
+ "@types/react": "^19.2.2",
44
+ "@types/react-dom": "^19.2.2",
45
+ "@vitejs/plugin-react-swc": "^4.2.0",
46
+ "eslint": "^9.38.0",
47
+ "eslint-plugin-react-hooks": "^7.0.1",
48
+ "eslint-plugin-react-refresh": "^0.4.24",
49
+ "globals": "^16.4.0",
50
+ "prettier": "3.6.2",
51
+ "tsc-alias": "^1.8.16",
52
+ "tw-animate-css": "^1.4.0",
53
+ "typescript": "~5.9.3",
54
+ "typescript-eslint": "^8.46.2",
55
+ "vite": "^7.1.12"
56
+ }
57
+ }
package/theme.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import { type ChartTheme } from '@/lib/types';
2
+ export declare const defaultTheme: ChartTheme;
package/theme.js ADDED
@@ -0,0 +1,31 @@
1
+ import {} from '@/lib/types';
2
+ export const defaultTheme = {
3
+ width: 928,
4
+ height: 600,
5
+ margins: {
6
+ top: 20,
7
+ right: 20,
8
+ bottom: 30,
9
+ left: 40,
10
+ },
11
+ gridColor: '#e0e0e0',
12
+ colorPalette: [
13
+ '#50b2fc', // ocean
14
+ '#ff4069', // ruby
15
+ '#55c7b4', // jade
16
+ '#ffce2e', // lemon
17
+ '#c27fec', // peacock
18
+ '#f99963', // sandstone
19
+ '#ff9fb4', // ruby-light
20
+ '#1f2a36', // cyberspace
21
+ ],
22
+ axis: {
23
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
24
+ fontSize: '14',
25
+ },
26
+ legend: {
27
+ boxSize: 20,
28
+ uncheckedColor: '#d0d0d0',
29
+ fontSize: 14,
30
+ },
31
+ };
package/title.d.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { type Selection } from 'd3';
2
+ import type { TitleConfig, ChartTheme } from './types';
3
+ import type { LayoutAwareComponent, ComponentSpace } from './chart-interface';
4
+ export declare class Title implements LayoutAwareComponent {
5
+ readonly type: "title";
6
+ readonly text: string;
7
+ private readonly fontSize;
8
+ private readonly fontWeight;
9
+ private readonly align;
10
+ private readonly marginTop;
11
+ private readonly marginBottom;
12
+ constructor(config: TitleConfig);
13
+ /**
14
+ * Returns the space required by the title
15
+ */
16
+ getRequiredSpace(): ComponentSpace;
17
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, theme: ChartTheme, width: number, x?: number, y?: number): void;
18
+ }
package/title.js ADDED
@@ -0,0 +1,88 @@
1
+ import {} from 'd3';
2
+ export class Title {
3
+ constructor(config) {
4
+ Object.defineProperty(this, "type", {
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true,
8
+ value: 'title'
9
+ });
10
+ Object.defineProperty(this, "text", {
11
+ enumerable: true,
12
+ configurable: true,
13
+ writable: true,
14
+ value: void 0
15
+ });
16
+ Object.defineProperty(this, "fontSize", {
17
+ enumerable: true,
18
+ configurable: true,
19
+ writable: true,
20
+ value: void 0
21
+ });
22
+ Object.defineProperty(this, "fontWeight", {
23
+ enumerable: true,
24
+ configurable: true,
25
+ writable: true,
26
+ value: void 0
27
+ });
28
+ Object.defineProperty(this, "align", {
29
+ enumerable: true,
30
+ configurable: true,
31
+ writable: true,
32
+ value: void 0
33
+ });
34
+ Object.defineProperty(this, "marginTop", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: void 0
39
+ });
40
+ Object.defineProperty(this, "marginBottom", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ this.text = config.text;
47
+ this.fontSize = config.fontSize ?? 18;
48
+ this.fontWeight = config.fontWeight ?? 'bold';
49
+ this.align = config.align ?? 'center';
50
+ this.marginTop = config.marginTop ?? 10;
51
+ this.marginBottom = config.marginBottom ?? 15;
52
+ }
53
+ /**
54
+ * Returns the space required by the title
55
+ */
56
+ getRequiredSpace() {
57
+ return {
58
+ width: 0, // Title spans full width
59
+ height: this.marginTop + this.fontSize + this.marginBottom,
60
+ position: 'top',
61
+ };
62
+ }
63
+ render(svg, theme, width, x = 0, y = 0) {
64
+ const titleGroup = svg
65
+ .append('g')
66
+ .attr('class', 'title')
67
+ .attr('transform', `translate(${x}, ${y})`);
68
+ let textX = width / 2;
69
+ let textAnchor = 'middle';
70
+ if (this.align === 'left') {
71
+ textX = theme.margins.left;
72
+ textAnchor = 'start';
73
+ }
74
+ else if (this.align === 'right') {
75
+ textX = width - theme.margins.right;
76
+ textAnchor = 'end';
77
+ }
78
+ titleGroup
79
+ .append('text')
80
+ .attr('x', textX)
81
+ .attr('y', this.marginTop + this.fontSize)
82
+ .attr('text-anchor', textAnchor)
83
+ .attr('font-size', `${this.fontSize}px`)
84
+ .attr('font-weight', this.fontWeight)
85
+ .attr('font-family', theme.axis.fontFamily)
86
+ .text(this.text);
87
+ }
88
+ }
package/tooltip.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import { type Selection } from 'd3';
2
+ import type { TooltipConfig, DataItem, ChartTheme } from './types';
3
+ import type { ChartComponent } from '@/lib/chart-interface';
4
+ import type { Line } from './line';
5
+ import type { Bar } from './bar';
6
+ import type { PlotAreaBounds } from './layout-manager';
7
+ export declare class Tooltip implements ChartComponent {
8
+ readonly type: "tooltip";
9
+ readonly formatter?: (dataKey: string, value: any, data: DataItem) => string;
10
+ private tooltipDiv;
11
+ constructor(config?: TooltipConfig);
12
+ initialize(theme: ChartTheme): void;
13
+ attachToArea(svg: Selection<SVGSVGElement, undefined, null, undefined>, data: DataItem[], series: (Line | Bar)[], xKey: string, x: any, y: any, _theme: ChartTheme, plotArea: PlotAreaBounds, parseValue: (value: any) => number): void;
14
+ cleanup(): void;
15
+ }