@internetstiftelsen/charts 0.5.1 → 0.6.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/theme.js CHANGED
@@ -1,3 +1,13 @@
1
+ export const DEFAULT_COLOR_PALETTE = [
2
+ '#50b2fc', // ocean
3
+ '#ff4069', // ruby
4
+ '#55c7b4', // jade
5
+ '#ffce2e', // lemon
6
+ '#c27fec', // peacock
7
+ '#f99963', // sandstone
8
+ '#ff9fb4', // ruby-light
9
+ '#1f2a36', // cyberspace
10
+ ];
1
11
  export const defaultTheme = {
2
12
  width: 928,
3
13
  height: 600,
@@ -5,32 +15,30 @@ export const defaultTheme = {
5
15
  margins: {
6
16
  top: 20,
7
17
  right: 20,
8
- bottom: 30,
9
- left: 40,
18
+ bottom: 20,
19
+ left: 20,
10
20
  },
11
21
  grid: {
12
22
  color: '#e0e0e0',
13
23
  opacity: 0.5,
14
24
  },
15
- colorPalette: [
16
- '#50b2fc', // ocean
17
- '#ff4069', // ruby
18
- '#55c7b4', // jade
19
- '#ffce2e', // lemon
20
- '#c27fec', // peacock
21
- '#f99963', // sandstone
22
- '#ff9fb4', // ruby-light
23
- '#1f2a36', // cyberspace
24
- ],
25
+ colorPalette: [...DEFAULT_COLOR_PALETTE],
25
26
  axis: {
26
27
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
27
28
  fontSize: '14',
28
29
  fontWeight: 'normal',
30
+ groupLabel: {
31
+ fontWeight: '700',
32
+ color: '#111827',
33
+ },
29
34
  },
30
35
  legend: {
31
36
  boxSize: 24,
32
37
  uncheckedColor: '#d0d0d0',
33
38
  fontSize: 14,
39
+ paddingX: 0,
40
+ itemSpacingX: 20,
41
+ itemSpacingY: 8,
34
42
  },
35
43
  line: {
36
44
  strokeWidth: 4,
@@ -45,7 +53,7 @@ export const defaultTheme = {
45
53
  fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
46
54
  fontWeight: '600',
47
55
  color: '#1f2a36',
48
- background: 'rgba(255, 255, 255, 0.95)',
56
+ background: '#ffffff',
49
57
  border: '#e0e0e0',
50
58
  borderRadius: 4,
51
59
  padding: 4,
@@ -80,8 +88,8 @@ export const newspaperTheme = {
80
88
  margins: {
81
89
  top: 20,
82
90
  right: 20,
83
- bottom: 30,
84
- left: 40,
91
+ bottom: 20,
92
+ left: 20,
85
93
  },
86
94
  grid: {
87
95
  color: '#2c2c2c',
@@ -101,11 +109,18 @@ export const newspaperTheme = {
101
109
  fontFamily: 'Georgia, "Times New Roman", Times, serif',
102
110
  fontSize: '13',
103
111
  fontWeight: '600',
112
+ groupLabel: {
113
+ fontWeight: '700',
114
+ color: '#1a1a1a',
115
+ },
104
116
  },
105
117
  legend: {
106
118
  boxSize: 18,
107
119
  uncheckedColor: '#d3d3d3',
108
120
  fontSize: 13,
121
+ paddingX: 0,
122
+ itemSpacingX: 20,
123
+ itemSpacingY: 8,
109
124
  },
110
125
  line: {
111
126
  strokeWidth: 2.5,
@@ -121,7 +136,7 @@ export const newspaperTheme = {
121
136
  fontFamily: 'Georgia, "Times New Roman", Times, serif',
122
137
  fontWeight: '600',
123
138
  color: '#1a1a1a',
124
- background: 'rgba(245, 245, 220, 0.95)',
139
+ background: '#ffffff',
125
140
  border: '#2c2c2c',
126
141
  borderRadius: 2,
127
142
  padding: 3,
package/tooltip.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { type Selection } from 'd3';
2
- import type { TooltipConfig, DataItem, DataValue, D3Scale, ChartTheme, ExportHooks, TooltipConfigBase } from './types.js';
2
+ import type { TooltipConfig, DataItem, DataValue, D3Scale, ChartTheme, ExportHooks, TooltipConfigBase, ScaleType } 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';
6
+ import type { Area } from './area.js';
6
7
  import type { PlotAreaBounds } from './layout-manager.js';
7
8
  export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
8
9
  readonly id = "iisChartTooltip";
@@ -20,6 +21,6 @@ export declare class Tooltip implements ChartComponent<TooltipConfigBase> {
20
21
  getExportConfig(): TooltipConfigBase;
21
22
  createExportComponent(override?: Partial<TooltipConfigBase>): ChartComponent;
22
23
  initialize(theme: ChartTheme): void;
23
- 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;
24
+ attachToArea(svg: Selection<SVGSVGElement, undefined, null, undefined>, data: DataItem[], series: (Line | Bar | Area)[], xKey: string, x: D3Scale, y: D3Scale, theme: ChartTheme, plotArea: PlotAreaBounds, parseValue: (value: unknown) => number, isHorizontal?: boolean, categoryScaleType?: ScaleType, resolveSeriesValue?: (series: Line | Bar | Area, dataPoint: DataItem, index: number) => number): void;
24
25
  cleanup(): void;
25
26
  }
package/tooltip.js CHANGED
@@ -86,31 +86,40 @@ export class Tooltip {
86
86
  .style('z-index', '1000');
87
87
  this.tooltipDiv = tooltip;
88
88
  }
89
- attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal = false) {
89
+ attachToArea(svg, data, series, xKey, x, y, theme, plotArea, parseValue, isHorizontal = false, categoryScaleType = 'band', resolveSeriesValue = (targetSeries, dataPoint) => {
90
+ const rawValue = dataPoint[targetSeries.dataKey];
91
+ if (rawValue === null || rawValue === undefined) {
92
+ return NaN;
93
+ }
94
+ return parseValue(rawValue);
95
+ }) {
90
96
  if (!this.tooltipDiv)
91
97
  return;
92
98
  const tooltip = this.tooltipDiv;
93
99
  const formatter = this.formatter;
94
100
  const labelFormatter = this.labelFormatter;
95
101
  const customFormatter = this.customFormatter;
96
- // Helper to get x position for any scale type
102
+ const getCategoryScaleValue = (value, scaleType) => {
103
+ switch (scaleType) {
104
+ case 'band':
105
+ return String(value);
106
+ case 'time':
107
+ return value instanceof Date
108
+ ? value
109
+ : new Date(String(value));
110
+ case 'linear':
111
+ case 'log':
112
+ return typeof value === 'number' ? value : Number(value);
113
+ }
114
+ };
97
115
  const getXPosition = (dataPoint) => {
98
116
  const xValue = dataPoint[xKey];
99
- const scaled = x(xValue instanceof Date
100
- ? xValue
101
- : typeof xValue === 'string'
102
- ? xValue
103
- : new Date(xValue));
117
+ const scaled = x(getCategoryScaleValue(xValue, categoryScaleType));
104
118
  return (scaled || 0) + (x.bandwidth ? x.bandwidth() / 2 : 0);
105
119
  };
106
- // Helper to get y position for category scale (used in horizontal orientation)
107
120
  const getYPosition = (dataPoint) => {
108
121
  const yValue = dataPoint[xKey];
109
- const scaled = y(yValue instanceof Date
110
- ? yValue
111
- : typeof yValue === 'string'
112
- ? yValue
113
- : new Date(yValue));
122
+ const scaled = y(getCategoryScaleValue(yValue, categoryScaleType));
114
123
  return (scaled || 0) + (y.bandwidth ? y.bandwidth() / 2 : 0);
115
124
  };
116
125
  // Create overlay rect for mouse tracking using plot area bounds
@@ -123,11 +132,9 @@ export class Tooltip {
123
132
  .attr('height', plotArea.height)
124
133
  .style('fill', 'none')
125
134
  .style('pointer-events', 'all');
126
- // Determine which series are lines vs bars
127
- const lineSeries = series.filter((s) => s.type === 'line');
135
+ const lineSeries = series.filter((s) => s.type === 'line' || s.type === 'area');
128
136
  const barSeries = series.filter((s) => s.type === 'bar');
129
137
  const hasBarSeries = barSeries.length > 0;
130
- // Create focus circles only for line series
131
138
  const focusCircles = lineSeries.map((s) => {
132
139
  const seriesColor = getSeriesColor(s);
133
140
  return svg
@@ -143,11 +150,9 @@ export class Tooltip {
143
150
  overlay
144
151
  .on('mousemove', (event) => {
145
152
  const [mouseX, mouseY] = pointer(event, svg.node());
146
- // Find closest data point based on orientation
147
153
  let closestIndex = 0;
148
154
  let dataPointPosition;
149
155
  if (isHorizontal) {
150
- // For horizontal charts, find closest by Y position (categories on Y axis)
151
156
  const yPositions = data.map((d) => getYPosition(d));
152
157
  let minDistance = Math.abs(mouseY - yPositions[0]);
153
158
  for (let i = 1; i < yPositions.length; i++) {
@@ -160,7 +165,6 @@ export class Tooltip {
160
165
  dataPointPosition = yPositions[closestIndex];
161
166
  }
162
167
  else {
163
- // For vertical charts, find closest by X position (categories on X axis)
164
168
  const xPositions = data.map((d) => getXPosition(d));
165
169
  let minDistance = Math.abs(mouseX - xPositions[0]);
166
170
  for (let i = 1; i < xPositions.length; i++) {
@@ -173,40 +177,40 @@ export class Tooltip {
173
177
  dataPointPosition = xPositions[closestIndex];
174
178
  }
175
179
  const dataPoint = data[closestIndex];
176
- // Update focus circles for line series
177
180
  lineSeries.forEach((s, i) => {
178
- const value = parseValue(dataPoint[s.dataKey]);
181
+ const value = resolveSeriesValue(s, dataPoint, closestIndex);
182
+ if (!Number.isFinite(value)) {
183
+ focusCircles[i].style('opacity', 0);
184
+ return;
185
+ }
179
186
  if (isHorizontal) {
180
- // Horizontal: cx = value position (X), cy = category position (Y)
181
187
  focusCircles[i]
182
188
  .attr('cx', x(value))
183
189
  .attr('cy', dataPointPosition)
184
190
  .style('opacity', 1);
185
191
  }
186
192
  else {
187
- // Vertical: cx = category position (X), cy = value position (Y)
188
193
  focusCircles[i]
189
194
  .attr('cx', dataPointPosition)
190
195
  .attr('cy', y(value))
191
196
  .style('opacity', 1);
192
197
  }
193
198
  });
194
- // Fade non-hovered bars
195
199
  if (hasBarSeries) {
196
200
  barSeries.forEach((s) => {
197
201
  const sanitizedKey = sanitizeForCSS(s.dataKey);
198
202
  svg.selectAll(`.bar-${sanitizedKey}`).style('opacity', (_, i) => (i === closestIndex ? 1 : 0.5));
199
203
  });
200
204
  }
201
- // Build tooltip content
202
205
  let content;
203
206
  if (customFormatter) {
204
207
  content = customFormatter(dataPoint, series);
205
208
  }
206
209
  else {
210
+ const labelValue = dataPoint[xKey];
207
211
  const label = labelFormatter
208
- ? labelFormatter(dataPoint[xKey], dataPoint)
209
- : dataPoint[xKey];
212
+ ? labelFormatter(String(labelValue), dataPoint)
213
+ : String(labelValue);
210
214
  content = `<strong>${label}</strong><br/>`;
211
215
  series.forEach((s) => {
212
216
  const value = dataPoint[s.dataKey];
@@ -220,23 +224,24 @@ export class Tooltip {
220
224
  }
221
225
  });
222
226
  }
223
- // Position tooltip: X anchored to data point, Y at midpoint of values
224
227
  tooltip.style('visibility', 'visible').html(content);
225
- // Get tooltip dimensions after content is set
226
228
  const tooltipNode = tooltip.node();
227
229
  const tooltipRect = tooltipNode.getBoundingClientRect();
228
230
  const tooltipWidth = tooltipRect.width;
229
231
  const tooltipHeight = tooltipRect.height;
230
232
  const svgRect = svg.node().getBoundingClientRect();
231
233
  const offsetX = 12;
232
- // Calculate min/max values across all series for this data point
233
- const values = series.map((s) => parseValue(dataPoint[s.dataKey]));
234
- const minValue = Math.min(...values);
235
- const maxValue = Math.max(...values);
234
+ const values = series.map((s) => resolveSeriesValue(s, dataPoint, closestIndex));
235
+ const finiteValues = values.filter((value) => Number.isFinite(value));
236
+ const minValue = finiteValues.length
237
+ ? Math.min(...finiteValues)
238
+ : 0;
239
+ const maxValue = finiteValues.length
240
+ ? Math.max(...finiteValues)
241
+ : 0;
236
242
  let tooltipX;
237
243
  let tooltipY;
238
244
  if (isHorizontal) {
239
- // Horizontal: X at midpoint of values, Y anchored to category
240
245
  const minX = x(minValue);
241
246
  const maxX = x(maxValue);
242
247
  const midX = (minX + maxX) / 2;
@@ -248,8 +253,7 @@ export class Tooltip {
248
253
  tooltipHeight / 2;
249
254
  }
250
255
  else {
251
- // Vertical: X anchored to category, Y at midpoint of values
252
- const minY = y(maxValue); // Note: Y scale is inverted
256
+ const minY = y(maxValue);
253
257
  const maxY = y(minValue);
254
258
  const midY = (minY + maxY) / 2;
255
259
  tooltipX =
@@ -260,7 +264,6 @@ export class Tooltip {
260
264
  tooltipY =
261
265
  svgRect.top + window.scrollY + midY - tooltipHeight / 2;
262
266
  }
263
- // Edge detection - flip horizontally if approaching right edge
264
267
  const viewportWidth = window.innerWidth;
265
268
  if (tooltipX + tooltipWidth > viewportWidth - 10) {
266
269
  if (isHorizontal) {
@@ -281,7 +284,6 @@ export class Tooltip {
281
284
  offsetX;
282
285
  }
283
286
  }
284
- // Ensure tooltip doesn't go off edges
285
287
  tooltipX = Math.max(10, tooltipX);
286
288
  tooltipY = Math.max(10, Math.min(tooltipY, window.innerHeight +
287
289
  window.scrollY -
@@ -294,7 +296,6 @@ export class Tooltip {
294
296
  .on('mouseout', () => {
295
297
  tooltip.style('visibility', 'hidden');
296
298
  focusCircles.forEach((circle) => circle.style('opacity', 0));
297
- // Reset bar opacity
298
299
  if (hasBarSeries) {
299
300
  barSeries.forEach((s) => {
300
301
  const sanitizedKey = sanitizeForCSS(s.dataKey);
package/types.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export type DataValue = string | number | boolean | Date | null | undefined;
2
2
  export type DataItem = Record<string, any>;
3
+ export type GroupedDataGroup = {
4
+ group: string;
5
+ data: DataItem[];
6
+ };
7
+ export type ChartData = DataItem[] | GroupedDataGroup[];
3
8
  export type ExportFormat = 'svg' | 'json' | 'csv' | 'xlsx' | 'png' | 'jpg' | 'pdf';
4
9
  export type ExportOptions = {
5
10
  download?: boolean;
@@ -47,11 +52,20 @@ export type ChartTheme = {
47
52
  fontFamily: string;
48
53
  fontSize: string;
49
54
  fontWeight?: string;
55
+ groupLabel?: {
56
+ fontFamily?: string;
57
+ fontSize?: string;
58
+ fontWeight?: string;
59
+ color?: string;
60
+ };
50
61
  };
51
62
  legend: {
52
63
  boxSize: number;
53
64
  uncheckedColor: string;
54
65
  fontSize: number;
66
+ paddingX: number;
67
+ itemSpacingX: number;
68
+ itemSpacingY: number;
55
69
  };
56
70
  line: {
57
71
  strokeWidth: number;
@@ -136,10 +150,32 @@ export type BarConfigBase = {
136
150
  export type BarConfig = BarConfigBase & {
137
151
  exportHooks?: ExportHooks<BarConfigBase>;
138
152
  };
153
+ export type AreaCurveType = 'linear' | 'monotone' | 'step' | 'natural' | 'basis' | 'cardinal';
154
+ export type AreaConfigBase = {
155
+ dataKey: string;
156
+ fill?: string;
157
+ stroke?: string;
158
+ strokeWidth?: number;
159
+ opacity?: number;
160
+ curve?: AreaCurveType;
161
+ stackId?: string | number;
162
+ baseline?: number;
163
+ showLine?: boolean;
164
+ showPoints?: boolean;
165
+ pointSize?: number;
166
+ valueLabel?: LineValueLabelConfig;
167
+ };
168
+ export type AreaConfig = AreaConfigBase & {
169
+ exportHooks?: ExportHooks<AreaConfigBase>;
170
+ };
139
171
  export type BarStackConfig = {
140
172
  mode?: BarStackMode;
141
173
  gap?: number;
142
174
  };
175
+ export type AreaStackMode = 'none' | 'normal' | 'percent';
176
+ export type AreaStackConfig = {
177
+ mode?: AreaStackMode;
178
+ };
143
179
  export declare function getSeriesColor(series: {
144
180
  stroke?: string;
145
181
  fill?: string;
@@ -147,6 +183,10 @@ export declare function getSeriesColor(series: {
147
183
  export type LabelOversizedBehavior = 'truncate' | 'wrap' | 'hide';
148
184
  export type XAxisConfigBase = {
149
185
  dataKey?: string;
186
+ labelKey?: string;
187
+ groupLabelKey?: string;
188
+ showGroupLabels?: boolean;
189
+ groupLabelGap?: number;
150
190
  rotatedLabels?: boolean;
151
191
  maxLabelWidth?: number;
152
192
  oversizedBehavior?: LabelOversizedBehavior;
@@ -190,6 +230,9 @@ export type LegendConfigBase = {
190
230
  position?: 'bottom';
191
231
  marginTop?: number;
192
232
  marginBottom?: number;
233
+ paddingX?: number;
234
+ itemSpacingX?: number;
235
+ itemSpacingY?: number;
193
236
  };
194
237
  export type LegendConfig = LegendConfigBase & {
195
238
  exportHooks?: ExportHooks<LegendConfigBase>;
@@ -219,6 +262,7 @@ export type ScaleConfig = {
219
262
  domain?: ScaleDomainValue[];
220
263
  range?: number[];
221
264
  padding?: number;
265
+ groupGap?: number;
222
266
  nice?: boolean;
223
267
  min?: number;
224
268
  max?: number;
@@ -237,3 +281,11 @@ export type BarStackingContext = {
237
281
  totalData: Map<string, number>;
238
282
  gap: number;
239
283
  };
284
+ export type AreaStackingContext = {
285
+ mode: AreaStackMode;
286
+ stackId: string | number;
287
+ seriesIndex: number;
288
+ totalSeries: number;
289
+ cumulativeData: Map<string, number>;
290
+ totalData: Map<string, number>;
291
+ };
package/validation.d.ts CHANGED
@@ -18,6 +18,10 @@ export declare class ChartValidator {
18
18
  * Validates that data contains at least one valid numeric value for the specified key
19
19
  */
20
20
  static validateNumericData(data: DataItem[], dataKey: string, componentName: string): void;
21
+ /**
22
+ * Validates that all non-null values are positive for logarithmic scales
23
+ */
24
+ static validatePositiveData(data: DataItem[], dataKey: string, componentName: string): void;
21
25
  /**
22
26
  * Validates scale configuration
23
27
  */
package/validation.js CHANGED
@@ -55,6 +55,31 @@ export class ChartValidator {
55
55
  throw new ChartValidationError(`${componentName}: No valid numeric values found for dataKey "${dataKey}"`);
56
56
  }
57
57
  }
58
+ /**
59
+ * Validates that all non-null values are positive for logarithmic scales
60
+ */
61
+ static validatePositiveData(data, dataKey, componentName) {
62
+ const invalidIndices = [];
63
+ data.forEach((item, index) => {
64
+ const rawValue = item[dataKey];
65
+ if (rawValue === null || rawValue === undefined) {
66
+ return;
67
+ }
68
+ const value = typeof rawValue === 'number'
69
+ ? rawValue
70
+ : parseFloat(String(rawValue));
71
+ if (!Number.isFinite(value) || value <= 0) {
72
+ invalidIndices.push(index);
73
+ }
74
+ });
75
+ if (invalidIndices.length > 0) {
76
+ const indices = invalidIndices.slice(0, 3).join(', ');
77
+ const more = invalidIndices.length > 3
78
+ ? ` and ${invalidIndices.length - 3} more`
79
+ : '';
80
+ throw new ChartValidationError(`${componentName}: Logarithmic scale requires values > 0 for dataKey "${dataKey}" (invalid at indices: ${indices}${more})`);
81
+ }
82
+ }
58
83
  /**
59
84
  * Validates scale configuration
60
85
  */
package/x-axis.d.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import { type Selection } from 'd3';
2
- import type { XAxisConfig, ChartTheme, D3Scale, ExportHooks, XAxisConfigBase } from './types.js';
2
+ import type { XAxisConfig, ChartTheme, D3Scale, DataItem, ExportHooks, XAxisConfigBase } from './types.js';
3
3
  import type { LayoutAwareComponent, ComponentSpace } from './chart-interface.js';
4
4
  export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
5
5
  readonly type: "xAxis";
6
6
  readonly dataKey?: string;
7
+ readonly labelKey?: string;
8
+ readonly groupLabelKey?: string;
9
+ readonly showGroupLabels: boolean;
10
+ readonly groupLabelGap: number;
7
11
  private readonly rotatedLabels;
8
12
  private readonly tickPadding;
9
13
  private fontSize;
@@ -16,6 +20,7 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
16
20
  private readonly minLabelGap;
17
21
  private readonly preserveEndLabels;
18
22
  readonly exportHooks?: ExportHooks<XAxisConfigBase>;
23
+ private resolveGroupLabelStyle;
19
24
  constructor(config?: XAxisConfig);
20
25
  getExportConfig(): XAxisConfigBase;
21
26
  createExportComponent(override?: Partial<XAxisConfigBase>): LayoutAwareComponent;
@@ -25,7 +30,10 @@ export declare class XAxis implements LayoutAwareComponent<XAxisConfigBase> {
25
30
  getRequiredSpace(): ComponentSpace;
26
31
  estimateLayoutSpace(labels: unknown[], theme: ChartTheme, svg: SVGSVGElement): void;
27
32
  clearEstimatedSpace(): void;
28
- render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number): void;
33
+ render(svg: Selection<SVGSVGElement, undefined, null, undefined>, x: D3Scale, theme: ChartTheme, yPosition: number, data?: DataItem[]): void;
34
+ private buildLabelLookup;
35
+ private renderGroupLabels;
36
+ private buildGroupRanges;
29
37
  private applyLabelConstraints;
30
38
  private wrapTextElement;
31
39
  private addTitleTooltip;