@internetstiftelsen/charts 0.5.1 → 0.6.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/x-axis.js CHANGED
@@ -1,6 +1,23 @@
1
1
  import { axisBottom } from 'd3';
2
2
  import { measureTextWidth, truncateText, wrapText, mergeDeep } from './utils.js';
3
+ import { GROUPED_CATEGORY_ID_KEY, GROUPED_CATEGORY_LABEL_KEY, GROUPED_GAP_TICK_PREFIX, GROUPED_GROUP_LABEL_KEY, } from './grouped-data.js';
3
4
  export class XAxis {
5
+ resolveGroupLabelStyle(theme) {
6
+ const axisGroupLabel = theme.axis.groupLabel;
7
+ const fontFamily = axisGroupLabel?.fontFamily ?? theme.axis.fontFamily;
8
+ const fontWeight = axisGroupLabel?.fontWeight ?? '700';
9
+ const parsedFontSize = parseFloat(axisGroupLabel?.fontSize ?? theme.axis.fontSize);
10
+ const fontSize = Number.isFinite(parsedFontSize)
11
+ ? parsedFontSize
12
+ : this.fontSize;
13
+ const color = axisGroupLabel?.color ?? '#111827';
14
+ return {
15
+ fontFamily,
16
+ fontWeight,
17
+ fontSize,
18
+ color,
19
+ };
20
+ }
4
21
  constructor(config) {
5
22
  Object.defineProperty(this, "type", {
6
23
  enumerable: true,
@@ -14,6 +31,30 @@ export class XAxis {
14
31
  writable: true,
15
32
  value: void 0
16
33
  });
34
+ Object.defineProperty(this, "labelKey", {
35
+ enumerable: true,
36
+ configurable: true,
37
+ writable: true,
38
+ value: void 0
39
+ });
40
+ Object.defineProperty(this, "groupLabelKey", {
41
+ enumerable: true,
42
+ configurable: true,
43
+ writable: true,
44
+ value: void 0
45
+ });
46
+ Object.defineProperty(this, "showGroupLabels", {
47
+ enumerable: true,
48
+ configurable: true,
49
+ writable: true,
50
+ value: void 0
51
+ });
52
+ Object.defineProperty(this, "groupLabelGap", {
53
+ enumerable: true,
54
+ configurable: true,
55
+ writable: true,
56
+ value: void 0
57
+ });
17
58
  Object.defineProperty(this, "rotatedLabels", {
18
59
  enumerable: true,
19
60
  configurable: true,
@@ -88,6 +129,10 @@ export class XAxis {
88
129
  value: void 0
89
130
  });
90
131
  this.dataKey = config?.dataKey;
132
+ this.labelKey = config?.labelKey;
133
+ this.groupLabelKey = config?.groupLabelKey;
134
+ this.showGroupLabels = config?.showGroupLabels ?? false;
135
+ this.groupLabelGap = config?.groupLabelGap ?? 10;
91
136
  this.rotatedLabels = config?.rotatedLabels ?? false;
92
137
  this.maxLabelWidth = config?.maxLabelWidth;
93
138
  this.oversizedBehavior = config?.oversizedBehavior ?? 'truncate';
@@ -100,6 +145,10 @@ export class XAxis {
100
145
  getExportConfig() {
101
146
  return {
102
147
  dataKey: this.dataKey,
148
+ labelKey: this.labelKey,
149
+ groupLabelKey: this.groupLabelKey,
150
+ showGroupLabels: this.showGroupLabels,
151
+ groupLabelGap: this.groupLabelGap,
103
152
  rotatedLabels: this.rotatedLabels,
104
153
  maxLabelWidth: this.maxLabelWidth,
105
154
  oversizedBehavior: this.oversizedBehavior,
@@ -137,6 +186,9 @@ export class XAxis {
137
186
  this.wrapLineCount > 1) {
138
187
  height += (this.wrapLineCount - 1) * this.fontSize * 1.2;
139
188
  }
189
+ if (this.showGroupLabels) {
190
+ height += this.groupLabelGap + this.fontSize + 5;
191
+ }
140
192
  return {
141
193
  width: 0, // X-axis spans full width
142
194
  height,
@@ -185,18 +237,33 @@ export class XAxis {
185
237
  else {
186
238
  this.estimatedHeight = this.tickPadding + textHeight + 5;
187
239
  }
240
+ if (this.showGroupLabels) {
241
+ const groupLabelStyle = this.resolveGroupLabelStyle(theme);
242
+ this.estimatedHeight +=
243
+ this.groupLabelGap + groupLabelStyle.fontSize + 5;
244
+ }
188
245
  this.wrapLineCount = Math.max(this.wrapLineCount, maxLines);
189
246
  }
190
247
  clearEstimatedSpace() {
191
248
  this.estimatedHeight = null;
192
249
  }
193
- render(svg, x, theme, yPosition) {
250
+ render(svg, x, theme, yPosition, data = []) {
251
+ const labelLookup = this.buildLabelLookup(data);
194
252
  const axisGenerator = axisBottom(x)
195
253
  .tickSizeOuter(0)
196
254
  .tickSize(0)
197
255
  .tickPadding(this.tickPadding);
198
- // Apply tick formatting if specified
199
- if (this.tickFormat) {
256
+ if (labelLookup) {
257
+ axisGenerator.tickFormat((value) => {
258
+ const key = String(value);
259
+ if (!labelLookup.has(key)) {
260
+ return '';
261
+ }
262
+ return labelLookup.get(key) ?? '';
263
+ });
264
+ }
265
+ else if (this.tickFormat) {
266
+ // Apply tick formatting if specified
200
267
  if (typeof this.tickFormat === 'function') {
201
268
  axisGenerator.tickFormat(this.tickFormat);
202
269
  }
@@ -226,6 +293,110 @@ export class XAxis {
226
293
  // Apply auto-hiding for overlapping labels
227
294
  this.applyAutoHiding(axis, x);
228
295
  axis.selectAll('.domain').remove();
296
+ if (this.showGroupLabels) {
297
+ this.renderGroupLabels(svg, x, theme, yPosition, data);
298
+ }
299
+ }
300
+ buildLabelLookup(data) {
301
+ if (!this.dataKey || data.length === 0) {
302
+ return null;
303
+ }
304
+ const labelKey = this.labelKey ??
305
+ (this.dataKey === GROUPED_CATEGORY_ID_KEY
306
+ ? GROUPED_CATEGORY_LABEL_KEY
307
+ : undefined);
308
+ if (!labelKey) {
309
+ return null;
310
+ }
311
+ const lookup = new Map();
312
+ data.forEach((row) => {
313
+ const rawDomainValue = row[this.dataKey];
314
+ const labelValue = row[labelKey];
315
+ lookup.set(String(rawDomainValue), String(labelValue ?? ''));
316
+ });
317
+ return lookup;
318
+ }
319
+ renderGroupLabels(svg, x, theme, yPosition, data) {
320
+ const groupRanges = this.buildGroupRanges(x, data);
321
+ if (groupRanges.length === 0) {
322
+ return;
323
+ }
324
+ const yOffset = this.tickPadding + this.fontSize + this.groupLabelGap;
325
+ const groupLabelStyle = this.resolveGroupLabelStyle(theme);
326
+ const groupLayer = svg
327
+ .append('g')
328
+ .attr('class', 'x-axis-group-labels')
329
+ .attr('transform', `translate(0,${yPosition})`);
330
+ groupLayer
331
+ .selectAll('text')
332
+ .data(groupRanges)
333
+ .join('text')
334
+ .attr('x', (range) => (range.start + range.end) / 2)
335
+ .attr('y', yOffset + groupLabelStyle.fontSize)
336
+ .attr('text-anchor', 'middle')
337
+ .attr('font-size', groupLabelStyle.fontSize)
338
+ .attr('font-family', groupLabelStyle.fontFamily)
339
+ .attr('font-weight', groupLabelStyle.fontWeight)
340
+ .attr('fill', groupLabelStyle.color)
341
+ .text((range) => range.label);
342
+ }
343
+ buildGroupRanges(scale, data) {
344
+ if (!this.dataKey ||
345
+ data.length === 0 ||
346
+ typeof scale.domain !== 'function' ||
347
+ typeof scale.bandwidth !== 'function') {
348
+ return [];
349
+ }
350
+ const groupLabelKey = this.groupLabelKey ??
351
+ (this.dataKey === GROUPED_CATEGORY_ID_KEY
352
+ ? GROUPED_GROUP_LABEL_KEY
353
+ : undefined);
354
+ if (!groupLabelKey) {
355
+ return [];
356
+ }
357
+ const domain = scale.domain().map((value) => String(value));
358
+ const bandwidth = scale.bandwidth();
359
+ if (domain.length === 0 || bandwidth <= 0) {
360
+ return [];
361
+ }
362
+ const groupLookup = new Map();
363
+ data.forEach((row) => {
364
+ groupLookup.set(String(row[this.dataKey]), String(row[groupLabelKey] ?? ''));
365
+ });
366
+ const ranges = [];
367
+ let currentLabel = null;
368
+ let startIndex = 0;
369
+ const pushRange = (from, to, label) => {
370
+ const startValue = domain[from];
371
+ const endValue = domain[to];
372
+ const start = scale(startValue);
373
+ const end = scale(endValue);
374
+ if (start === undefined || end === undefined) {
375
+ return;
376
+ }
377
+ ranges.push({
378
+ label,
379
+ start: start,
380
+ end: end + bandwidth,
381
+ });
382
+ };
383
+ domain.forEach((domainValue, index) => {
384
+ const groupLabel = groupLookup.get(domainValue) ?? '';
385
+ if (currentLabel === null) {
386
+ currentLabel = groupLabel;
387
+ startIndex = index;
388
+ return;
389
+ }
390
+ if (groupLabel !== currentLabel) {
391
+ pushRange(startIndex, index - 1, currentLabel);
392
+ currentLabel = groupLabel;
393
+ startIndex = index;
394
+ }
395
+ });
396
+ if (currentLabel !== null) {
397
+ pushRange(startIndex, domain.length - 1, currentLabel);
398
+ }
399
+ return ranges.filter((range) => range.label.trim() !== '');
229
400
  }
230
401
  applyLabelConstraints(axisGroup, svg, fontSize, fontFamily, fontWeight) {
231
402
  if (!this.maxLabelWidth)
@@ -288,15 +459,36 @@ export class XAxis {
288
459
  applyAutoHiding(axisGroup, scale) {
289
460
  if (!this.autoHideOverlapping)
290
461
  return;
291
- const textElements = axisGroup
292
- .selectAll('text')
462
+ const tickElements = axisGroup
463
+ .selectAll('.tick')
293
464
  .nodes();
294
- const labelCount = textElements.length;
295
- if (labelCount <= 1)
465
+ const labelEntries = tickElements
466
+ .map((tickElement) => {
467
+ const textElement = tickElement.querySelector('text');
468
+ const tickValue = String(tickElement
469
+ .__data__ ?? '');
470
+ const isSyntheticGapTick = tickValue.startsWith(GROUPED_GAP_TICK_PREFIX);
471
+ if (isSyntheticGapTick && textElement) {
472
+ textElement.style.visibility = 'hidden';
473
+ }
474
+ return {
475
+ textElement,
476
+ isSyntheticGapTick,
477
+ };
478
+ })
479
+ .filter((entry) => {
480
+ return !entry.isSyntheticGapTick && entry.textElement !== null;
481
+ })
482
+ .map((entry) => {
483
+ return entry.textElement;
484
+ });
485
+ const labelCount = labelEntries.length;
486
+ if (labelCount <= 1) {
296
487
  return;
488
+ }
297
489
  // Measure all label widths
298
490
  let maxLabelWidth = 0;
299
- for (const textEl of textElements) {
491
+ for (const textEl of labelEntries) {
300
492
  const bbox = textEl.getBBox();
301
493
  // For rotated labels, use the horizontal footprint
302
494
  const effectiveWidth = this.rotatedLabels
@@ -304,11 +496,9 @@ export class XAxis {
304
496
  : bbox.width;
305
497
  maxLabelWidth = Math.max(maxLabelWidth, effectiveWidth);
306
498
  }
307
- // Calculate available space per label
308
- const bandwidth = typeof scale.bandwidth === 'function' ? scale.bandwidth() : 0;
309
- const availableSpace = bandwidth > 0
310
- ? bandwidth
311
- : (scale.range()[1] - scale.range()[0]) / labelCount;
499
+ // Calculate available space per real label only.
500
+ // This deliberately ignores synthetic grouped-gap ticks.
501
+ const availableSpace = (scale.range()[1] - scale.range()[0]) / labelCount;
312
502
  // Calculate skip interval
313
503
  const requiredSpace = maxLabelWidth + this.minLabelGap;
314
504
  const skipInterval = Math.ceil(requiredSpace / availableSpace);
@@ -316,7 +506,7 @@ export class XAxis {
316
506
  if (skipInterval <= 1)
317
507
  return;
318
508
  // Apply visibility
319
- textElements.forEach((textEl, index) => {
509
+ labelEntries.forEach((textEl, index) => {
320
510
  const isFirst = index === 0;
321
511
  const isLast = index === labelCount - 1;
322
512
  const isAtInterval = index % skipInterval === 0;
@@ -336,7 +526,7 @@ export class XAxis {
336
526
  if (lastVisibleIntervalIndex !== labelCount - 1 &&
337
527
  labelCount - 1 - lastVisibleIntervalIndex < skipInterval) {
338
528
  // Hide the last interval label to avoid overlap with preserved last label
339
- const textEl = textElements[lastVisibleIntervalIndex];
529
+ const textEl = labelEntries[lastVisibleIntervalIndex];
340
530
  if (textEl && lastVisibleIntervalIndex !== 0) {
341
531
  textEl.style.visibility = 'hidden';
342
532
  }
package/xy-chart.d.ts CHANGED
@@ -1,13 +1,15 @@
1
- import type { BarStackConfig } from './types.js';
2
1
  import { BaseChart, type BaseChartConfig } from './base-chart.js';
3
2
  import type { ChartComponent } from './chart-interface.js';
3
+ import { type AreaStackConfig, type BarStackConfig } from './types.js';
4
4
  export type XYChartConfig = BaseChartConfig & {
5
5
  barStack?: BarStackConfig;
6
+ areaStack?: AreaStackConfig;
6
7
  };
7
8
  export declare class XYChart extends BaseChart {
8
9
  private readonly series;
9
10
  private barStackMode;
10
11
  private barStackGap;
12
+ private areaStackMode;
11
13
  constructor(config: XYChartConfig);
12
14
  addChild(component: ChartComponent): this;
13
15
  protected getExportComponents(): ChartComponent[];
@@ -16,9 +18,16 @@ export declare class XYChart extends BaseChart {
16
18
  protected prepareLayout(): void;
17
19
  protected renderChart(): void;
18
20
  private getXKey;
21
+ private getCategoryScaleType;
22
+ private getVisibleSeries;
19
23
  private setupScales;
20
24
  private isHorizontalOrientation;
25
+ private collectSeriesValues;
26
+ private getStackedAreaGroups;
27
+ private buildBandDomainWithGroupGaps;
21
28
  private createScale;
29
+ private getSeriesTooltipValue;
22
30
  private renderSeries;
23
31
  private computeStackingData;
32
+ private computeAreaStackingContexts;
24
33
  }