@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 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: any, y: any, parseValue: (value: any) => number, xScaleType?: ScaleType, theme?: ChartTheme, stackingContext?: BarStackingContext): void;
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 = value instanceof Date ? value : new Date(value);
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-${this.dataKey.replace(/\s+/g, '-')}`)
137
+ .selectAll(`.bar-${sanitizedKey}`)
135
138
  .data(data)
136
139
  .join('rect')
137
- .attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
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-${this.dataKey.replace(/\s+/g, '-')}`)
241
+ .selectAll(`.bar-${sanitizedKey}`)
237
242
  .data(data)
238
243
  .join('rect')
239
- .attr('class', `bar-${this.dataKey.replace(/\s+/g, '-')}`)
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.replace(/\s+/g, '-')}`);
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
- let labelX = barCenterX;
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.replace(/\s+/g, '-')}`);
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
- let labelY = barCenterY;
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: any;
34
- protected y: any;
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: any): number;
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
- return typeof value === 'string' ? parseFloat(value) : value;
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
- if (format === 'svg') {
232
- return this.exportSVG();
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 this.exportJSON();
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: any, y: any, theme: ChartTheme): void;
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: any, y: any, parseValue: (value: any) => number, xScaleType: ScaleType | undefined, theme: ChartTheme): void;
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 or value as-is
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-${this.dataKey.replace(/\s+/g, '-')}`)
84
- .data(data)
92
+ .selectAll(`.circle-${sanitizedKey}`)
93
+ .data(validData)
85
94
  .join('circle')
86
- .attr('class', `circle-${this.dataKey.replace(/\s+/g, '-')}`)
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, data, y, parseValue, theme, getXPosition);
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.replace(/\s+/g, '-')}`);
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
- let labelX = xPos;
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.0.9",
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: any, data: DataItem) => string;
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
- [key: string]: any;
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: any, y: any, theme: ChartTheme, plotArea: PlotAreaBounds, parseValue: (value: any) => number): void;
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
- // Create focus circles for each series
91
- const focusCircles = series.map((s) => {
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.replace(/\s+/g, '-')}`)
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 x value
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 minDistance = Math.abs(mouseX - xPositions[0]);
110
- for (let i = 1; i < xPositions.length; i++) {
111
- const distance = Math.abs(mouseX - xPositions[i]);
112
- if (distance < minDistance) {
113
- minDistance = distance;
114
- closestIndex = i;
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
- const xPos = xPositions[closestIndex];
119
- // Update focus circles
120
- series.forEach((s, i) => {
121
- const yValue = parseValue(dataPoint[s.dataKey]);
122
- const yPos = y(yValue);
123
- focusCircles[i]
124
- .attr('cx', xPos)
125
- .attr('cy', yPos)
126
- .style('opacity', 1);
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
- const tooltipX = svgRect.left + window.scrollX + xPos + 10;
153
- const tooltipY = svgRect.top +
154
- window.scrollY +
155
- y(parseValue(dataPoint[series[0].dataKey])) -
156
- 10;
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 DataItem = {
2
- [key: string]: any;
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: any) => string) | null;
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: any, data: DataItem) => string;
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
- [key: string]: any;
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?: any[];
128
- range?: any[];
140
+ domain?: ScaleDomainValue[];
141
+ range?: number[];
129
142
  padding?: number;
130
143
  nice?: boolean;
131
144
  min?: number;