@internetstiftelsen/charts 0.0.8 → 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 +2 -2
- package/bar.js +306 -51
- package/base-chart.d.ts +17 -4
- package/base-chart.js +57 -1
- package/grid.d.ts +2 -2
- package/line.d.ts +2 -2
- package/line.js +9 -7
- package/package.json +11 -2
- package/tooltip.d.ts +5 -4
- package/tooltip.js +101 -27
- package/types.d.ts +33 -11
- package/utils.d.ts +60 -0
- package/utils.js +165 -0
- package/validation.d.ts +3 -3
- package/x-axis.d.ts +8 -2
- package/x-axis.js +87 -1
- package/xy-chart.d.ts +7 -1
- package/xy-chart.js +88 -11
- package/y-axis.d.ts +8 -2
- package/y-axis.js +103 -7
package/bar.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Selection } from 'd3';
|
|
2
|
-
import type { BarConfig,
|
|
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:
|
|
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 =
|
|
61
|
+
scaledValue =
|
|
62
|
+
value instanceof Date ? value : new Date(String(value));
|
|
61
63
|
break;
|
|
62
64
|
case 'linear':
|
|
63
65
|
case 'log':
|
|
@@ -66,90 +68,267 @@ export class Bar {
|
|
|
66
68
|
}
|
|
67
69
|
return scale(scaledValue) || 0;
|
|
68
70
|
}
|
|
69
|
-
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme) {
|
|
71
|
+
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext) {
|
|
70
72
|
if (this.orientation === 'vertical') {
|
|
71
|
-
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
73
|
+
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
72
74
|
}
|
|
73
75
|
else {
|
|
74
|
-
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType);
|
|
76
|
+
this.renderHorizontal(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
75
77
|
}
|
|
76
78
|
// Render value labels if enabled
|
|
77
79
|
if (this.valueLabel?.show && theme) {
|
|
78
80
|
if (this.orientation === 'vertical') {
|
|
79
|
-
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
81
|
+
this.renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
80
82
|
}
|
|
81
83
|
else {
|
|
82
|
-
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme);
|
|
84
|
+
this.renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext);
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
|
-
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType) {
|
|
88
|
+
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
|
|
87
89
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
88
|
-
const
|
|
90
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
91
|
+
// Calculate bar width based on stacking mode
|
|
92
|
+
let barWidth;
|
|
93
|
+
let barOffset;
|
|
94
|
+
if (mode === 'none') {
|
|
95
|
+
// Grouped bars: divide bandwidth among series with gap
|
|
96
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
97
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
98
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
99
|
+
const groupWidth = this.maxBarSize
|
|
100
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
101
|
+
: bandwidth;
|
|
102
|
+
// Calculate total gap space and individual bar width
|
|
103
|
+
const totalGapSpace = groupWidth * gap * (totalSeries - 1);
|
|
104
|
+
const availableWidth = groupWidth - totalGapSpace;
|
|
105
|
+
barWidth = availableWidth / totalSeries;
|
|
106
|
+
const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
|
|
107
|
+
barOffset =
|
|
108
|
+
(bandwidth - groupWidth) / 2 +
|
|
109
|
+
seriesIndex * (barWidth + gapSize);
|
|
110
|
+
}
|
|
111
|
+
else if (mode === 'layer') {
|
|
112
|
+
// Layer mode: each subsequent series has smaller bars
|
|
113
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
114
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
115
|
+
const maxWidth = this.maxBarSize
|
|
116
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
117
|
+
: bandwidth;
|
|
118
|
+
// Scale from 100% to a minimum (e.g., 30%) based on series position
|
|
119
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
120
|
+
barWidth = maxWidth * scaleFactor;
|
|
121
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// Normal and Percent modes: full width stacked bars
|
|
125
|
+
barWidth = this.maxBarSize
|
|
126
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
127
|
+
: bandwidth;
|
|
128
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
129
|
+
}
|
|
89
130
|
// Get the baseline value from the Y scale's domain
|
|
90
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain max (bottom of chart)
|
|
91
|
-
// For log scales, use the minimum value from the domain
|
|
92
131
|
const yDomain = y.domain();
|
|
93
132
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
94
133
|
const yBaseline = y(baselineValue) || 0;
|
|
95
134
|
// Add bar rectangles
|
|
135
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
96
136
|
plotGroup
|
|
97
|
-
.selectAll(`.bar-${
|
|
137
|
+
.selectAll(`.bar-${sanitizedKey}`)
|
|
98
138
|
.data(data)
|
|
99
139
|
.join('rect')
|
|
100
|
-
.attr('class', `bar-${
|
|
140
|
+
.attr('class', `bar-${sanitizedKey}`)
|
|
141
|
+
.attr('data-index', (_, i) => i)
|
|
101
142
|
.attr('x', (d) => {
|
|
102
143
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
144
|
+
return xScaleType === 'band'
|
|
145
|
+
? xPos + barOffset
|
|
146
|
+
: xPos - barWidth / 2;
|
|
106
147
|
})
|
|
107
148
|
.attr('y', (d) => {
|
|
108
|
-
const
|
|
109
|
-
|
|
149
|
+
const categoryKey = String(d[xKey]);
|
|
150
|
+
const value = parseValue(d[this.dataKey]);
|
|
151
|
+
if (mode === 'none' || mode === 'layer') {
|
|
152
|
+
// No stacking - each bar starts from baseline
|
|
153
|
+
const yPos = y(value) || 0;
|
|
154
|
+
return Math.min(yBaseline, yPos);
|
|
155
|
+
}
|
|
156
|
+
else if (mode === 'percent') {
|
|
157
|
+
// Percent mode: calculate position based on cumulative percentage
|
|
158
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
159
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
160
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
161
|
+
const percentValue = (value / total) * 100;
|
|
162
|
+
return y(percentCumulative + percentValue) || 0;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Normal stacking mode
|
|
166
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
167
|
+
return y(cumulative + value) || 0;
|
|
168
|
+
}
|
|
110
169
|
})
|
|
111
170
|
.attr('width', barWidth)
|
|
112
171
|
.attr('height', (d) => {
|
|
113
|
-
const
|
|
114
|
-
|
|
172
|
+
const categoryKey = String(d[xKey]);
|
|
173
|
+
const value = parseValue(d[this.dataKey]);
|
|
174
|
+
if (mode === 'none' || mode === 'layer') {
|
|
175
|
+
const yPos = y(value) || 0;
|
|
176
|
+
return Math.abs(yBaseline - yPos);
|
|
177
|
+
}
|
|
178
|
+
else if (mode === 'percent') {
|
|
179
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
180
|
+
const percentValue = (value / total) * 100;
|
|
181
|
+
const yTop = y(percentValue) || 0;
|
|
182
|
+
const yBottom = y(0) || 0;
|
|
183
|
+
return Math.abs(yBottom - yTop);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
// Normal stacking mode
|
|
187
|
+
const yTop = y(value) || 0;
|
|
188
|
+
return Math.abs(yBaseline - yTop);
|
|
189
|
+
}
|
|
115
190
|
})
|
|
116
191
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
117
192
|
}
|
|
118
|
-
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType) {
|
|
193
|
+
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
|
|
119
194
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
120
|
-
const
|
|
195
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
196
|
+
// Calculate bar height based on stacking mode
|
|
197
|
+
let barHeight;
|
|
198
|
+
let barOffset;
|
|
199
|
+
if (mode === 'none') {
|
|
200
|
+
// Grouped bars: divide bandwidth among series with gap
|
|
201
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
202
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
203
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
204
|
+
const groupHeight = this.maxBarSize
|
|
205
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
206
|
+
: bandwidth;
|
|
207
|
+
// Calculate total gap space and individual bar height
|
|
208
|
+
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
209
|
+
const availableHeight = groupHeight - totalGapSpace;
|
|
210
|
+
barHeight = availableHeight / totalSeries;
|
|
211
|
+
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
212
|
+
barOffset =
|
|
213
|
+
(bandwidth - groupHeight) / 2 +
|
|
214
|
+
seriesIndex * (barHeight + gapSize);
|
|
215
|
+
}
|
|
216
|
+
else if (mode === 'layer') {
|
|
217
|
+
// Layer mode: each subsequent series has smaller bars
|
|
218
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
219
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
220
|
+
const maxHeight = this.maxBarSize
|
|
221
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
222
|
+
: bandwidth;
|
|
223
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
224
|
+
barHeight = maxHeight * scaleFactor;
|
|
225
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
// Normal and Percent modes: full height stacked bars
|
|
229
|
+
barHeight = this.maxBarSize
|
|
230
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
231
|
+
: bandwidth;
|
|
232
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
233
|
+
}
|
|
121
234
|
// Get the baseline value from the scale's domain
|
|
122
|
-
// For linear scales, use 0 if it's in the domain, otherwise use domain min
|
|
123
|
-
// For log scales, use the minimum value from the domain
|
|
124
235
|
const domain = x.domain();
|
|
125
236
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
126
237
|
const xBaseline = x(baselineValue) || 0;
|
|
127
238
|
// Add bar rectangles (horizontal)
|
|
239
|
+
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
128
240
|
plotGroup
|
|
129
|
-
.selectAll(`.bar-${
|
|
241
|
+
.selectAll(`.bar-${sanitizedKey}`)
|
|
130
242
|
.data(data)
|
|
131
243
|
.join('rect')
|
|
132
|
-
.attr('class', `bar-${
|
|
244
|
+
.attr('class', `bar-${sanitizedKey}`)
|
|
245
|
+
.attr('data-index', (_, i) => i)
|
|
133
246
|
.attr('x', (d) => {
|
|
134
|
-
const
|
|
135
|
-
|
|
247
|
+
const categoryKey = String(d[xKey]);
|
|
248
|
+
const value = parseValue(d[this.dataKey]);
|
|
249
|
+
if (mode === 'none' || mode === 'layer') {
|
|
250
|
+
const xPos = x(value) || 0;
|
|
251
|
+
return Math.min(xBaseline, xPos);
|
|
252
|
+
}
|
|
253
|
+
else if (mode === 'percent') {
|
|
254
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
255
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
256
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
257
|
+
return x(percentCumulative) || 0;
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
// Normal stacking mode
|
|
261
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
262
|
+
return x(cumulative) || 0;
|
|
263
|
+
}
|
|
136
264
|
})
|
|
137
265
|
.attr('y', (d) => {
|
|
138
266
|
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
267
|
+
return yScaleType === 'band'
|
|
268
|
+
? yPos + barOffset
|
|
269
|
+
: yPos - barHeight / 2;
|
|
142
270
|
})
|
|
143
271
|
.attr('width', (d) => {
|
|
144
|
-
const
|
|
145
|
-
|
|
272
|
+
const categoryKey = String(d[xKey]);
|
|
273
|
+
const value = parseValue(d[this.dataKey]);
|
|
274
|
+
if (mode === 'none' || mode === 'layer') {
|
|
275
|
+
const xPos = x(value) || 0;
|
|
276
|
+
return Math.abs(xPos - xBaseline);
|
|
277
|
+
}
|
|
278
|
+
else if (mode === 'percent') {
|
|
279
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
280
|
+
const percentValue = (value / total) * 100;
|
|
281
|
+
const xLeft = x(0) || 0;
|
|
282
|
+
const xRight = x(percentValue) || 0;
|
|
283
|
+
return Math.abs(xRight - xLeft);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
// Normal stacking mode
|
|
287
|
+
const xLeft = x(0) || 0;
|
|
288
|
+
const xRight = x(value) || 0;
|
|
289
|
+
return Math.abs(xRight - xLeft);
|
|
290
|
+
}
|
|
146
291
|
})
|
|
147
292
|
.attr('height', barHeight)
|
|
148
293
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
149
294
|
}
|
|
150
|
-
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme) {
|
|
295
|
+
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
|
|
151
296
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
152
|
-
const
|
|
297
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
298
|
+
// Calculate bar width based on stacking mode (same logic as renderVertical)
|
|
299
|
+
let barWidth;
|
|
300
|
+
let barOffset;
|
|
301
|
+
if (mode === 'none') {
|
|
302
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
303
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
304
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
305
|
+
const groupWidth = this.maxBarSize
|
|
306
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
307
|
+
: bandwidth;
|
|
308
|
+
const totalGapSpace = groupWidth * gap * (totalSeries - 1);
|
|
309
|
+
const availableWidth = groupWidth - totalGapSpace;
|
|
310
|
+
barWidth = availableWidth / totalSeries;
|
|
311
|
+
const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
|
|
312
|
+
barOffset =
|
|
313
|
+
(bandwidth - groupWidth) / 2 +
|
|
314
|
+
seriesIndex * (barWidth + gapSize);
|
|
315
|
+
}
|
|
316
|
+
else if (mode === 'layer') {
|
|
317
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
318
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
319
|
+
const maxWidth = this.maxBarSize
|
|
320
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
321
|
+
: bandwidth;
|
|
322
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
323
|
+
barWidth = maxWidth * scaleFactor;
|
|
324
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
barWidth = this.maxBarSize
|
|
328
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
329
|
+
: bandwidth;
|
|
330
|
+
barOffset = (bandwidth - barWidth) / 2;
|
|
331
|
+
}
|
|
153
332
|
const yDomain = y.domain();
|
|
154
333
|
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
155
334
|
const yBaseline = y(baselineValue) || 0;
|
|
@@ -166,16 +345,37 @@ export class Bar {
|
|
|
166
345
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
167
346
|
const labelGroup = plotGroup
|
|
168
347
|
.append('g')
|
|
169
|
-
.attr('class', `bar-value-labels-${this.dataKey
|
|
348
|
+
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
170
349
|
data.forEach((d) => {
|
|
350
|
+
const categoryKey = String(d[xKey]);
|
|
171
351
|
const value = parseValue(d[this.dataKey]);
|
|
172
352
|
const valueText = String(value);
|
|
173
353
|
const xPos = this.getScaledPosition(d, xKey, x, xScaleType);
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
354
|
+
// Calculate bar position based on stacking mode
|
|
355
|
+
let barTop;
|
|
356
|
+
let barBottom;
|
|
357
|
+
if (mode === 'none' || mode === 'layer') {
|
|
358
|
+
const yPos = y(value) || 0;
|
|
359
|
+
barTop = Math.min(yBaseline, yPos);
|
|
360
|
+
barBottom = Math.max(yBaseline, yPos);
|
|
361
|
+
}
|
|
362
|
+
else if (mode === 'percent') {
|
|
363
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
364
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
365
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
366
|
+
const percentValue = (value / total) * 100;
|
|
367
|
+
barTop = y(percentCumulative + percentValue) || 0;
|
|
368
|
+
barBottom = y(percentCumulative) || 0;
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
372
|
+
barTop = y(cumulative + value) || 0;
|
|
373
|
+
barBottom = y(cumulative) || 0;
|
|
374
|
+
}
|
|
375
|
+
const barHeight = Math.abs(barBottom - barTop);
|
|
376
|
+
const barCenterX = xPos +
|
|
377
|
+
(xScaleType === 'band' ? barOffset : -barWidth / 2) +
|
|
378
|
+
barWidth / 2;
|
|
179
379
|
// Create temporary text to measure dimensions
|
|
180
380
|
const tempText = labelGroup
|
|
181
381
|
.append('text')
|
|
@@ -186,7 +386,7 @@ export class Bar {
|
|
|
186
386
|
const textBBox = tempText.node().getBBox();
|
|
187
387
|
const boxWidth = textBBox.width + padding * 2;
|
|
188
388
|
const boxHeight = textBBox.height + padding * 2;
|
|
189
|
-
|
|
389
|
+
const labelX = barCenterX;
|
|
190
390
|
let labelY;
|
|
191
391
|
let shouldRender = true;
|
|
192
392
|
if (position === 'outside') {
|
|
@@ -249,9 +449,43 @@ export class Bar {
|
|
|
249
449
|
}
|
|
250
450
|
});
|
|
251
451
|
}
|
|
252
|
-
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme) {
|
|
452
|
+
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
|
|
253
453
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
254
|
-
const
|
|
454
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
455
|
+
// Calculate bar height based on stacking mode (same logic as renderHorizontal)
|
|
456
|
+
let barHeight;
|
|
457
|
+
let barOffset;
|
|
458
|
+
if (mode === 'none') {
|
|
459
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
460
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
461
|
+
const gap = stackingContext?.gap ?? 0.1;
|
|
462
|
+
const groupHeight = this.maxBarSize
|
|
463
|
+
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
464
|
+
: bandwidth;
|
|
465
|
+
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
466
|
+
const availableHeight = groupHeight - totalGapSpace;
|
|
467
|
+
barHeight = availableHeight / totalSeries;
|
|
468
|
+
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
469
|
+
barOffset =
|
|
470
|
+
(bandwidth - groupHeight) / 2 +
|
|
471
|
+
seriesIndex * (barHeight + gapSize);
|
|
472
|
+
}
|
|
473
|
+
else if (mode === 'layer') {
|
|
474
|
+
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
475
|
+
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
476
|
+
const maxHeight = this.maxBarSize
|
|
477
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
478
|
+
: bandwidth;
|
|
479
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
480
|
+
barHeight = maxHeight * scaleFactor;
|
|
481
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
barHeight = this.maxBarSize
|
|
485
|
+
? Math.min(bandwidth, this.maxBarSize)
|
|
486
|
+
: bandwidth;
|
|
487
|
+
barOffset = (bandwidth - barHeight) / 2;
|
|
488
|
+
}
|
|
255
489
|
const domain = x.domain();
|
|
256
490
|
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
257
491
|
const xBaseline = x(baselineValue) || 0;
|
|
@@ -268,16 +502,37 @@ export class Bar {
|
|
|
268
502
|
const padding = config.padding ?? theme.valueLabel.padding;
|
|
269
503
|
const labelGroup = plotGroup
|
|
270
504
|
.append('g')
|
|
271
|
-
.attr('class', `bar-value-labels-${this.dataKey
|
|
505
|
+
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
272
506
|
data.forEach((d) => {
|
|
507
|
+
const categoryKey = String(d[xKey]);
|
|
273
508
|
const value = parseValue(d[this.dataKey]);
|
|
274
509
|
const valueText = String(value);
|
|
275
510
|
const yPos = this.getScaledPosition(d, xKey, y, yScaleType);
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
511
|
+
// Calculate bar position based on stacking mode
|
|
512
|
+
let barLeft;
|
|
513
|
+
let barRight;
|
|
514
|
+
if (mode === 'none' || mode === 'layer') {
|
|
515
|
+
const xPos = x(value) || 0;
|
|
516
|
+
barLeft = Math.min(xBaseline, xPos);
|
|
517
|
+
barRight = Math.max(xBaseline, xPos);
|
|
518
|
+
}
|
|
519
|
+
else if (mode === 'percent') {
|
|
520
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
521
|
+
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
522
|
+
const percentCumulative = (cumulative / total) * 100;
|
|
523
|
+
const percentValue = (value / total) * 100;
|
|
524
|
+
barLeft = x(percentCumulative) || 0;
|
|
525
|
+
barRight = x(percentCumulative + percentValue) || 0;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
529
|
+
barLeft = x(cumulative) || 0;
|
|
530
|
+
barRight = x(cumulative + value) || 0;
|
|
531
|
+
}
|
|
532
|
+
const barWidth = Math.abs(barRight - barLeft);
|
|
533
|
+
const barCenterY = yPos +
|
|
534
|
+
(yScaleType === 'band' ? barOffset : -barHeight / 2) +
|
|
535
|
+
barHeight / 2;
|
|
281
536
|
// Create temporary text to measure dimensions
|
|
282
537
|
const tempText = labelGroup
|
|
283
538
|
.append('text')
|
|
@@ -289,7 +544,7 @@ export class Bar {
|
|
|
289
544
|
const boxWidth = textBBox.width + padding * 2;
|
|
290
545
|
const boxHeight = textBBox.height + padding * 2;
|
|
291
546
|
let labelX;
|
|
292
|
-
|
|
547
|
+
const labelY = barCenterY;
|
|
293
548
|
let shouldRender = true;
|
|
294
549
|
if (position === 'outside') {
|
|
295
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 } 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:
|
|
34
|
-
protected y:
|
|
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,5 +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:
|
|
71
|
+
protected parseValue(value: unknown): number;
|
|
72
|
+
/**
|
|
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
|
|
77
|
+
*/
|
|
78
|
+
export(format: ExportFormat, options?: ExportOptions): string | void;
|
|
79
|
+
/**
|
|
80
|
+
* Downloads the exported content as a file
|
|
81
|
+
*/
|
|
82
|
+
private downloadContent;
|
|
83
|
+
protected exportSVG(): string;
|
|
84
|
+
protected exportJSON(): string;
|
|
72
85
|
}
|
package/base-chart.js
CHANGED
|
@@ -222,6 +222,62 @@ export class BaseChart {
|
|
|
222
222
|
this.y = null;
|
|
223
223
|
}
|
|
224
224
|
parseValue(value) {
|
|
225
|
-
|
|
225
|
+
if (typeof value === 'string') {
|
|
226
|
+
return parseFloat(value);
|
|
227
|
+
}
|
|
228
|
+
if (typeof value === 'number') {
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
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
|
|
238
|
+
*/
|
|
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;
|
|
246
|
+
}
|
|
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);
|
|
265
|
+
}
|
|
266
|
+
exportSVG() {
|
|
267
|
+
if (!this.svg) {
|
|
268
|
+
throw new Error('Chart must be rendered before export');
|
|
269
|
+
}
|
|
270
|
+
const clone = this.svg.node().cloneNode(true);
|
|
271
|
+
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
272
|
+
clone.setAttribute('width', String(this.width));
|
|
273
|
+
clone.setAttribute('height', String(this.theme.height));
|
|
274
|
+
return clone.outerHTML;
|
|
275
|
+
}
|
|
276
|
+
exportJSON() {
|
|
277
|
+
return JSON.stringify({
|
|
278
|
+
data: this.data,
|
|
279
|
+
theme: this.theme,
|
|
280
|
+
scales: this.scaleConfig,
|
|
281
|
+
}, null, 2);
|
|
226
282
|
}
|
|
227
283
|
}
|
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:
|
|
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:
|
|
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
|
}
|