@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 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':
@@ -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-${this.dataKey.replace(/\s+/g, '-')}`)
85
+ .selectAll(`.circle-${sanitizedKey}`)
84
86
  .data(data)
85
87
  .join('circle')
86
- .attr('class', `circle-${this.dataKey.replace(/\s+/g, '-')}`)
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.replace(/\s+/g, '-')}`);
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
- let labelX = xPos;
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.9",
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: 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,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: any) => string) | null;
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: any, data: DataItem) => string;
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
- [key: string]: any;
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?: any[];
128
- range?: any[];
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: any): asserts data is DataItem[];
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: any[] | undefined): void;
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: any, theme: ChartTheme, yPosition: number): void;
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
- const height = this.rotatedLabels ? baseHeight * 2.5 : baseHeight;
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: any, theme: ChartTheme, xPosition: number): void;
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: 40
21
- }); // Estimated max width for Y-axis labels
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: this.maxLabelWidth + this.tickPadding,
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.append('g')
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
- .selectAll('.domain')
62
- .remove();
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
  }