@internetstiftelsen/charts 0.8.0 → 0.9.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.
@@ -0,0 +1,199 @@
1
+ import cloud from 'd3-cloud';
2
+ import { scaleSqrt } from 'd3';
3
+ import { BaseChart, } from './base-chart.js';
4
+ import { isGroupedData } from './grouped-data.js';
5
+ const DEFAULT_OPTIONS = {
6
+ maxWords: 75,
7
+ minWordLength: 1,
8
+ minValue: 1,
9
+ minFontSize: 3,
10
+ maxFontSize: 20,
11
+ padding: 1,
12
+ rotation: undefined,
13
+ spiral: 'archimedean',
14
+ };
15
+ const GROUPED_DATA_ERROR = 'WordCloudChart: grouped datasets are not supported; provide a flat array of rows instead';
16
+ function createPreparedWords(data, plotArea, options, colors) {
17
+ const counts = new Map();
18
+ data.forEach((row) => {
19
+ const word = row.word.trim();
20
+ const count = row.count;
21
+ if (!word ||
22
+ word.length < options.minWordLength ||
23
+ !Number.isFinite(count) ||
24
+ count < options.minValue) {
25
+ return;
26
+ }
27
+ counts.set(word, (counts.get(word) ?? 0) + count);
28
+ });
29
+ if (counts.size === 0) {
30
+ throw new Error('WordCloudChart: no valid words remain after filtering; adjust minWordLength or minValue, or provide valid rows');
31
+ }
32
+ const words = Array.from(counts.entries())
33
+ .map(([text, value]) => ({ text, value }))
34
+ .sort((left, right) => right.value - left.value)
35
+ .slice(0, options.maxWords);
36
+ const baseDimension = Math.max(1, Math.min(plotArea.width, plotArea.height));
37
+ const minFontSize = (baseDimension * options.minFontSize) / 100;
38
+ const maxFontSize = (baseDimension * options.maxFontSize) / 100;
39
+ const maxValue = words[0].value;
40
+ const minValue = words[words.length - 1].value;
41
+ const fontScale = maxValue === minValue
42
+ ? null
43
+ : scaleSqrt()
44
+ .domain([minValue, maxValue])
45
+ .range([minFontSize, maxFontSize]);
46
+ return words.map((word, index) => {
47
+ return {
48
+ text: word.text,
49
+ value: word.value,
50
+ size: fontScale ? fontScale(word.value) : maxFontSize,
51
+ color: colors[index % colors.length] ?? '#000000',
52
+ };
53
+ });
54
+ }
55
+ export class WordCloudChart extends BaseChart {
56
+ constructor(config) {
57
+ super(config);
58
+ Object.defineProperty(this, "options", {
59
+ enumerable: true,
60
+ configurable: true,
61
+ writable: true,
62
+ value: void 0
63
+ });
64
+ Object.defineProperty(this, "layout", {
65
+ enumerable: true,
66
+ configurable: true,
67
+ writable: true,
68
+ value: null
69
+ });
70
+ Object.defineProperty(this, "layoutRunId", {
71
+ enumerable: true,
72
+ configurable: true,
73
+ writable: true,
74
+ value: 0
75
+ });
76
+ Object.defineProperty(this, "resolvePendingReady", {
77
+ enumerable: true,
78
+ configurable: true,
79
+ writable: true,
80
+ value: null
81
+ });
82
+ const wordCloud = config.wordCloud ?? {};
83
+ this.options = {
84
+ maxWords: wordCloud.maxWords ?? DEFAULT_OPTIONS.maxWords,
85
+ minWordLength: wordCloud.minWordLength ?? DEFAULT_OPTIONS.minWordLength,
86
+ minValue: wordCloud.minValue ?? DEFAULT_OPTIONS.minValue,
87
+ minFontSize: wordCloud.minFontSize ?? DEFAULT_OPTIONS.minFontSize,
88
+ maxFontSize: wordCloud.maxFontSize ?? DEFAULT_OPTIONS.maxFontSize,
89
+ padding: wordCloud.padding ?? DEFAULT_OPTIONS.padding,
90
+ rotation: wordCloud.rotation,
91
+ spiral: wordCloud.spiral ?? DEFAULT_OPTIONS.spiral,
92
+ };
93
+ this.initializeDataState();
94
+ }
95
+ destroy() {
96
+ this.layoutRunId += 1;
97
+ this.stopLayout();
98
+ this.setReadyPromise(Promise.resolve());
99
+ super.destroy();
100
+ }
101
+ validateSourceData(data) {
102
+ if (isGroupedData(data)) {
103
+ throw new Error(GROUPED_DATA_ERROR);
104
+ }
105
+ }
106
+ renderChart({ svg, plotArea }) {
107
+ this.stopLayout();
108
+ this.renderTitle(svg);
109
+ const words = createPreparedWords(this.data, plotArea, this.options, this.renderTheme.colorPalette);
110
+ this.setReadyPromise(new Promise((resolve) => {
111
+ this.resolvePendingReady = resolve;
112
+ this.startLayout(words, plotArea, ++this.layoutRunId, resolve);
113
+ }));
114
+ }
115
+ createExportChart() {
116
+ return new WordCloudChart({
117
+ data: this.sourceData,
118
+ theme: this.theme,
119
+ responsive: this.responsiveConfig,
120
+ wordCloud: this.options,
121
+ });
122
+ }
123
+ startLayout(words, plotArea, runId, resolve) {
124
+ const layout = cloud()
125
+ .words(words.map((word) => ({ ...word })))
126
+ .size([
127
+ Math.max(1, Math.floor(plotArea.width)),
128
+ Math.max(1, Math.floor(plotArea.height)),
129
+ ])
130
+ .padding(this.options.padding)
131
+ .spiral(this.options.spiral)
132
+ .font(this.renderTheme.fontFamily)
133
+ .fontWeight(this.renderTheme.valueLabel.fontWeight)
134
+ .fontSize((word) => word.size)
135
+ .text((word) => word.text)
136
+ .on('end', (placedWords) => {
137
+ this.layout = null;
138
+ if (runId !== this.layoutRunId ||
139
+ !this.plotGroup ||
140
+ !this.plotArea) {
141
+ this.finishReady(resolve);
142
+ return;
143
+ }
144
+ if (placedWords.length < words.length) {
145
+ console.warn(`[Chart Warning] WordCloudChart: rendered ${placedWords.length} of ${words.length} words within the available area; reduce maxWords or font sizes to fit more words`);
146
+ }
147
+ this.renderWords(this.plotGroup, this.plotArea, placedWords);
148
+ this.finishReady(resolve);
149
+ });
150
+ if (this.options.rotation === 'none') {
151
+ layout.rotate(0);
152
+ }
153
+ else if (this.options.rotation === 'right-angle') {
154
+ layout.rotate((_word, index) => (index % 2 === 0 ? 0 : 90));
155
+ }
156
+ this.layout = layout;
157
+ layout.start();
158
+ }
159
+ renderWords(plotGroup, plotArea, words) {
160
+ plotGroup.attr('transform', `translate(${plotArea.left}, ${plotArea.top})`);
161
+ plotGroup
162
+ .append('rect')
163
+ .attr('class', 'word-cloud-viewport')
164
+ .attr('x', 0)
165
+ .attr('y', 0)
166
+ .attr('width', plotArea.width)
167
+ .attr('height', plotArea.height)
168
+ .attr('fill', 'transparent')
169
+ .attr('stroke', 'none')
170
+ .attr('pointer-events', 'none');
171
+ plotGroup
172
+ .append('g')
173
+ .attr('class', 'word-cloud')
174
+ .attr('transform', `translate(${plotArea.width / 2}, ${plotArea.height / 2})`)
175
+ .selectAll('text')
176
+ .data(words)
177
+ .join('text')
178
+ .attr('class', 'word-cloud-word')
179
+ .attr('text-anchor', 'middle')
180
+ .style('font-family', this.renderTheme.fontFamily)
181
+ .style('font-weight', String(this.renderTheme.valueLabel.fontWeight))
182
+ .style('font-size', (word) => `${word.size}px`)
183
+ .style('fill', (word) => word.color)
184
+ .attr('transform', (word) => `translate(${word.x ?? 0}, ${word.y ?? 0}) rotate(${word.rotate ?? 0})`)
185
+ .text((word) => word.text);
186
+ }
187
+ stopLayout() {
188
+ if (this.layout) {
189
+ this.layout.stop();
190
+ this.layout = null;
191
+ }
192
+ this.resolvePendingReady?.();
193
+ this.resolvePendingReady = null;
194
+ }
195
+ finishReady(resolve) {
196
+ this.resolvePendingReady = null;
197
+ resolve();
198
+ }
199
+ }
package/xy-chart.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { BaseChart, type BaseChartConfig } from './base-chart.js';
1
+ import { BaseChart, type BaseChartConfig, type BaseLayoutContext, type BaseRenderContext } from './base-chart.js';
2
2
  import type { ChartComponent } from './chart-interface.js';
3
3
  import { type AreaStackConfig, type BarStackConfig, type LegendSeries } from './types.js';
4
4
  export type XYChartConfig = BaseChartConfig & {
@@ -16,14 +16,16 @@ export declare class XYChart extends BaseChart {
16
16
  protected getExportComponents(): ChartComponent[];
17
17
  protected createExportChart(): BaseChart;
18
18
  protected applyComponentOverrides(overrides: Map<ChartComponent, ChartComponent>): () => void;
19
- private rerender;
20
- protected prepareLayout(): void;
21
- protected renderChart(): void;
19
+ protected prepareLayout(context: BaseLayoutContext): void;
20
+ protected renderChart({ svg, plotGroup, plotArea, }: BaseRenderContext): void;
22
21
  private getXKey;
23
22
  protected getLegendSeries(): LegendSeries[];
24
23
  private getCategoryScaleType;
25
24
  private getVisibleSeries;
26
25
  private getDisplaySeries;
26
+ private resolveSeriesDefaults;
27
+ private shouldReplaceSeriesColor;
28
+ private cloneSeriesWithOverride;
27
29
  private setupScales;
28
30
  private isHorizontalOrientation;
29
31
  private collectSeriesValues;
package/xy-chart.js CHANGED
@@ -1,7 +1,13 @@
1
1
  import { max, min, scaleBand, scaleLinear, scaleLog, scaleTime, } from 'd3';
2
- import { BaseChart } from './base-chart.js';
2
+ import { BaseChart, } from './base-chart.js';
3
3
  import { ChartValidator } from './validation.js';
4
4
  import { GROUPED_GAP_TICK_PREFIX, GROUPED_GROUP_LABEL_KEY, } from './grouped-data.js';
5
+ const DEFAULT_SERIES_COLOR = '#8884d8';
6
+ function isXYSeries(component) {
7
+ return (component.type === 'line' ||
8
+ component.type === 'bar' ||
9
+ component.type === 'area');
10
+ }
5
11
  export class XYChart extends BaseChart {
6
12
  constructor(config) {
7
13
  super(config);
@@ -41,82 +47,26 @@ export class XYChart extends BaseChart {
41
47
  this.areaStackMode = config.areaStack?.mode ?? 'none';
42
48
  }
43
49
  addChild(component) {
44
- const type = component.type;
45
- if (type === 'line' || type === 'bar' || type === 'area') {
46
- const series = component;
47
- const defaultColor = '#8884d8';
48
- const colorIndex = this.series.length % this.theme.colorPalette.length;
49
- const newColor = this.theme.colorPalette[colorIndex];
50
- if (type === 'line') {
51
- const lineSeries = series;
52
- const currentColor = lineSeries.stroke;
53
- if (!currentColor || currentColor === defaultColor) {
54
- lineSeries.stroke = newColor;
55
- }
56
- }
57
- else if (type === 'bar') {
58
- const barSeries = series;
59
- const currentColor = barSeries.fill;
60
- if (!currentColor || currentColor === defaultColor) {
61
- barSeries.fill = newColor;
62
- }
63
- }
64
- else {
65
- const areaSeries = series;
66
- const isUsingDefaultColor = (!areaSeries.fill || areaSeries.fill === defaultColor) &&
67
- (!areaSeries.stroke || areaSeries.stroke === defaultColor);
68
- if (isUsingDefaultColor) {
69
- areaSeries.fill = newColor;
70
- areaSeries.stroke = newColor;
71
- }
72
- }
73
- this.series.push(series);
74
- }
75
- else if (type === 'xAxis') {
76
- this.xAxis = component;
77
- }
78
- else if (type === 'yAxis') {
79
- this.yAxis = component;
80
- }
81
- else if (type === 'grid') {
82
- this.grid = component;
83
- }
84
- else if (type === 'tooltip') {
85
- this.tooltip = component;
50
+ if (isXYSeries(component)) {
51
+ this.series.push(this.resolveSeriesDefaults(component));
52
+ return this;
86
53
  }
87
- else if (type === 'legend') {
88
- this.legend = component;
89
- this.legend.setToggleCallback(() => {
90
- this.rerender();
91
- });
92
- }
93
- else if (type === 'title') {
94
- this.title = component;
95
- }
96
- return this;
54
+ return super.addChild(component);
97
55
  }
98
56
  getExportComponents() {
99
- const components = [];
100
- if (this.title) {
101
- components.push(this.title);
102
- }
103
- if (this.grid) {
104
- components.push(this.grid);
105
- }
106
- components.push(...this.series);
107
- if (this.xAxis) {
108
- components.push(this.xAxis);
109
- }
110
- if (this.yAxis) {
111
- components.push(this.yAxis);
112
- }
113
- if (this.tooltip) {
114
- components.push(this.tooltip);
115
- }
116
- if (this.legend) {
117
- components.push(this.legend);
118
- }
119
- return components;
57
+ return [
58
+ ...this.getBaseExportComponents({
59
+ title: true,
60
+ grid: true,
61
+ }),
62
+ ...this.series,
63
+ ...this.getBaseExportComponents({
64
+ xAxis: true,
65
+ yAxis: true,
66
+ tooltip: true,
67
+ legend: true,
68
+ }),
69
+ ];
120
70
  }
121
71
  createExportChart() {
122
72
  return new XYChart({
@@ -136,34 +86,16 @@ export class XYChart extends BaseChart {
136
86
  }
137
87
  applyComponentOverrides(overrides) {
138
88
  const restoreBase = super.applyComponentOverrides(overrides);
139
- if (overrides.size === 0) {
140
- return restoreBase;
141
- }
142
- const previousSeries = [...this.series];
143
- this.series.forEach((series, index) => {
144
- const override = overrides.get(series);
145
- if (override &&
146
- (override.type === 'line' ||
147
- override.type === 'bar' ||
148
- override.type === 'area')) {
149
- this.series[index] = override;
150
- }
151
- });
89
+ const restoreSeries = this.applyArrayComponentOverrides(this.series, overrides, isXYSeries);
152
90
  return () => {
153
- this.series.splice(0, this.series.length, ...previousSeries);
91
+ restoreSeries();
154
92
  restoreBase();
155
93
  };
156
94
  }
157
- rerender() {
158
- if (!this.container) {
159
- return;
160
- }
161
- this.update(this.sourceData);
162
- }
163
- prepareLayout() {
164
- const svgNode = this.svg?.node();
95
+ prepareLayout(context) {
96
+ super.prepareLayout(context);
165
97
  this.xAxis?.clearEstimatedSpace?.();
166
- if (svgNode && this.xAxis) {
98
+ if (this.xAxis) {
167
99
  const xKey = this.getXKey();
168
100
  const labelKey = this.xAxis.labelKey;
169
101
  const labels = this.data.map((item) => {
@@ -172,16 +104,10 @@ export class XYChart extends BaseChart {
172
104
  }
173
105
  return item[xKey];
174
106
  });
175
- this.xAxis.estimateLayoutSpace?.(labels, this.theme, svgNode);
176
- }
177
- if (svgNode && this.legend?.isInlineMode()) {
178
- this.legend.estimateLayoutSpace(this.getLegendSeries(), this.theme, this.width, svgNode);
107
+ this.xAxis.estimateLayoutSpace?.(labels, this.renderTheme, context.svgNode);
179
108
  }
180
109
  }
181
- renderChart() {
182
- if (!this.plotArea) {
183
- throw new Error('Plot area not calculated');
184
- }
110
+ renderChart({ svg, plotGroup, plotArea, }) {
185
111
  this.series.forEach((series) => {
186
112
  const typeName = series.type === 'line'
187
113
  ? 'Line'
@@ -209,32 +135,26 @@ export class XYChart extends BaseChart {
209
135
  const categoryScaleType = this.getCategoryScaleType();
210
136
  const visibleSeries = this.getVisibleSeries();
211
137
  this.setupScales();
212
- if (this.title) {
213
- const titlePos = this.layoutManager.getComponentPosition(this.title);
214
- this.title.render(this.svg, this.theme, this.width, titlePos.x, titlePos.y);
215
- }
138
+ this.renderTitle(svg);
216
139
  if (this.grid && this.x && this.y) {
217
- this.grid.render(this.plotGroup, this.x, this.y, this.theme);
140
+ this.grid.render(plotGroup, this.x, this.y, this.renderTheme);
218
141
  }
219
142
  this.renderSeries(visibleSeries);
220
143
  if (this.x && this.y) {
221
144
  if (this.xAxis) {
222
- this.xAxis.render(this.svg, this.x, this.theme, this.plotArea.bottom, this.data);
145
+ this.xAxis.render(svg, this.x, this.renderTheme, plotArea.bottom, this.data);
223
146
  }
224
147
  if (this.yAxis) {
225
- this.yAxis.render(this.svg, this.y, this.theme, this.plotArea.left);
148
+ this.yAxis.render(svg, this.y, this.renderTheme, plotArea.left);
226
149
  }
227
150
  }
228
151
  if (this.tooltip && this.x && this.y) {
229
152
  const visibleAreaSeries = visibleSeries.filter((series) => series.type === 'area');
230
153
  const areaStackingContextBySeries = this.computeAreaStackingContexts(this.data, xKey, visibleAreaSeries);
231
- this.tooltip.initialize(this.theme);
232
- this.tooltip.attachToArea(this.svg, this.data, visibleSeries, xKey, this.x, this.y, this.theme, this.plotArea, this.parseValue.bind(this), this.isHorizontalOrientation(), categoryScaleType, (series, dataPoint) => this.getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries));
233
- }
234
- if (this.legend?.isInlineMode()) {
235
- const legendPos = this.layoutManager.getComponentPosition(this.legend);
236
- this.legend.render(this.svg, this.getLegendSeries(), this.theme, this.width, legendPos.x, legendPos.y);
154
+ this.tooltip.initialize(this.renderTheme);
155
+ this.tooltip.attachToArea(svg, this.data, visibleSeries, xKey, this.x, this.y, this.renderTheme, plotArea, this.parseValue.bind(this), this.isHorizontalOrientation(), categoryScaleType, (series, dataPoint) => this.getSeriesTooltipValue(series, dataPoint, xKey, areaStackingContextBySeries));
237
156
  }
157
+ this.renderInlineLegend(svg);
238
158
  }
239
159
  getXKey() {
240
160
  if (this.xAxis?.dataKey) {
@@ -261,11 +181,9 @@ export class XYChart extends BaseChart {
261
181
  return this.scaleConfig.x?.type || 'band';
262
182
  }
263
183
  getVisibleSeries() {
264
- const displaySeries = this.getDisplaySeries();
265
- if (!this.legend) {
266
- return displaySeries;
267
- }
268
- return displaySeries.filter((series) => this.legend.isSeriesVisible(series.dataKey));
184
+ return this.filterVisibleItems(this.getDisplaySeries(), (series) => {
185
+ return series.dataKey;
186
+ });
269
187
  }
270
188
  getDisplaySeries() {
271
189
  if (!this.barStackReverseSeries) {
@@ -288,6 +206,39 @@ export class XYChart extends BaseChart {
288
206
  return nextBar;
289
207
  });
290
208
  }
209
+ resolveSeriesDefaults(series) {
210
+ const colorIndex = this.series.length % this.theme.colorPalette.length;
211
+ const paletteColor = this.theme.colorPalette[colorIndex];
212
+ if (series.type === 'line') {
213
+ return this.cloneSeriesWithOverride(series, {
214
+ stroke: this.shouldReplaceSeriesColor(series.stroke)
215
+ ? paletteColor
216
+ : series.stroke,
217
+ });
218
+ }
219
+ if (series.type === 'bar') {
220
+ return this.cloneSeriesWithOverride(series, {
221
+ fill: this.shouldReplaceSeriesColor(series.fill)
222
+ ? paletteColor
223
+ : series.fill,
224
+ });
225
+ }
226
+ const shouldUsePaletteColor = this.shouldReplaceSeriesColor(series.fill) &&
227
+ this.shouldReplaceSeriesColor(series.stroke);
228
+ return this.cloneSeriesWithOverride(series, shouldUsePaletteColor
229
+ ? {
230
+ fill: paletteColor,
231
+ stroke: paletteColor,
232
+ }
233
+ : {});
234
+ }
235
+ shouldReplaceSeriesColor(color) {
236
+ return !color || color === DEFAULT_SERIES_COLOR;
237
+ }
238
+ cloneSeriesWithOverride(series, override) {
239
+ const exportable = series;
240
+ return exportable.createExportComponent(override);
241
+ }
291
242
  setupScales() {
292
243
  const xKey = this.getXKey();
293
244
  const isHorizontal = this.isHorizontalOrientation();
@@ -555,13 +506,13 @@ export class XYChart extends BaseChart {
555
506
  gap: this.barStackGap,
556
507
  nextLayerData,
557
508
  };
558
- series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, stackingContext);
509
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.renderTheme, stackingContext);
559
510
  });
560
511
  areaSeries.forEach((series) => {
561
- series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme, areaStackingContextBySeries.get(series), areaValueLabelLayer ?? undefined);
512
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.renderTheme, areaStackingContextBySeries.get(series), areaValueLabelLayer ?? undefined);
562
513
  });
563
514
  lineSeries.forEach((series) => {
564
- series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.theme);
515
+ series.render(this.plotGroup, this.data, xKey, this.x, this.y, this.parseValue, categoryScaleType, this.renderTheme);
565
516
  });
566
517
  if (areaValueLabelLayer) {
567
518
  areaValueLabelLayer.raise();