@internetstiftelsen/charts 0.9.2 → 0.10.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/README.md +137 -3
- package/dist/area.d.ts +2 -0
- package/dist/area.js +39 -31
- package/dist/bar.d.ts +20 -1
- package/dist/bar.js +395 -519
- package/dist/base-chart.d.ts +21 -1
- package/dist/base-chart.js +166 -93
- package/dist/chart-group.d.ts +137 -0
- package/dist/chart-group.js +1155 -0
- package/dist/chart-interface.d.ts +1 -1
- package/dist/donut-center-content.d.ts +1 -0
- package/dist/donut-center-content.js +21 -38
- package/dist/donut-chart.js +30 -15
- package/dist/gauge-chart.d.ts +20 -0
- package/dist/gauge-chart.js +229 -133
- package/dist/legend-state.d.ts +19 -0
- package/dist/legend-state.js +81 -0
- package/dist/legend.d.ts +5 -2
- package/dist/legend.js +45 -38
- package/dist/line.js +3 -1
- package/dist/pie-chart.d.ts +3 -0
- package/dist/pie-chart.js +45 -19
- package/dist/scatter.d.ts +16 -0
- package/dist/scatter.js +165 -0
- package/dist/tooltip.d.ts +2 -1
- package/dist/tooltip.js +21 -25
- package/dist/types.d.ts +19 -1
- package/dist/utils.js +11 -19
- package/dist/validation.d.ts +4 -0
- package/dist/validation.js +19 -0
- package/dist/x-axis.d.ts +10 -0
- package/dist/x-axis.js +190 -149
- package/dist/xy-chart.d.ts +40 -1
- package/dist/xy-chart.js +488 -165
- package/dist/y-axis.d.ts +7 -2
- package/dist/y-axis.js +99 -10
- package/docs/chart-group.md +213 -0
- package/docs/components.md +321 -0
- package/docs/donut-chart.md +193 -0
- package/docs/gauge-chart.md +175 -0
- package/docs/getting-started.md +311 -0
- package/docs/pie-chart.md +123 -0
- package/docs/theming.md +162 -0
- package/docs/word-cloud-chart.md +98 -0
- package/docs/xy-chart.md +517 -0
- package/package.json +6 -4
package/dist/bar.js
CHANGED
|
@@ -4,7 +4,7 @@ const LABEL_INSET_DEFAULT = 4;
|
|
|
4
4
|
const LABEL_INSET_STACKED = 6;
|
|
5
5
|
const LABEL_MIN_PADDING_DEFAULT = 8;
|
|
6
6
|
const LABEL_MIN_PADDING_STACKED = 16;
|
|
7
|
-
const
|
|
7
|
+
const LABEL_OUTSIDE_OFFSET = 4;
|
|
8
8
|
function getLabelSpacing(mode) {
|
|
9
9
|
const stacked = mode !== 'none';
|
|
10
10
|
return {
|
|
@@ -14,6 +14,109 @@ function getLabelSpacing(mode) {
|
|
|
14
14
|
: LABEL_MIN_PADDING_DEFAULT,
|
|
15
15
|
};
|
|
16
16
|
}
|
|
17
|
+
function getBarSlotLayout(bandwidth, mode, maxBarSize, totalSeries, seriesIndex, gap) {
|
|
18
|
+
if (mode === 'none') {
|
|
19
|
+
const groupSize = maxBarSize
|
|
20
|
+
? Math.min(bandwidth, maxBarSize * totalSeries)
|
|
21
|
+
: bandwidth;
|
|
22
|
+
const totalGapSpace = groupSize * gap * (totalSeries - 1);
|
|
23
|
+
const availableSize = groupSize - totalGapSpace;
|
|
24
|
+
const thickness = availableSize / totalSeries;
|
|
25
|
+
const gapSize = totalSeries > 1 ? groupSize * gap : 0;
|
|
26
|
+
return {
|
|
27
|
+
thickness,
|
|
28
|
+
offset: (bandwidth - groupSize) / 2 +
|
|
29
|
+
seriesIndex * (thickness + gapSize),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (mode === 'layer') {
|
|
33
|
+
const maxSize = maxBarSize
|
|
34
|
+
? Math.min(bandwidth, maxBarSize)
|
|
35
|
+
: bandwidth;
|
|
36
|
+
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
37
|
+
const thickness = maxSize * scaleFactor;
|
|
38
|
+
return {
|
|
39
|
+
thickness,
|
|
40
|
+
offset: (bandwidth - thickness) / 2,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const thickness = maxBarSize ? Math.min(bandwidth, maxBarSize) : bandwidth;
|
|
44
|
+
return {
|
|
45
|
+
thickness,
|
|
46
|
+
offset: (bandwidth - thickness) / 2,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function getBarValueRange(categoryKey, value, stackingContext) {
|
|
50
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
51
|
+
if (mode === 'none' || mode === 'layer') {
|
|
52
|
+
return getUnstackedBarValueRange(value);
|
|
53
|
+
}
|
|
54
|
+
if (mode === 'percent') {
|
|
55
|
+
return getPercentBarValueRange(categoryKey, value, stackingContext);
|
|
56
|
+
}
|
|
57
|
+
return getStackedBarValueRange(categoryKey, value, stackingContext);
|
|
58
|
+
}
|
|
59
|
+
function getUnstackedBarValueRange(value) {
|
|
60
|
+
return {
|
|
61
|
+
start: 0,
|
|
62
|
+
end: value,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function getPercentBarValueRange(categoryKey, value, stackingContext) {
|
|
66
|
+
const isPositive = value >= 0;
|
|
67
|
+
const { cumulative, total } = getPercentBarRangeMetrics(categoryKey, isPositive, stackingContext);
|
|
68
|
+
if (total === 0) {
|
|
69
|
+
return {
|
|
70
|
+
start: 0,
|
|
71
|
+
end: 0,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const start = (cumulative / total) * 100;
|
|
75
|
+
const end = ((cumulative + Math.abs(value)) / total) * 100;
|
|
76
|
+
return isPositive ? { start, end } : { start: -start, end: -end };
|
|
77
|
+
}
|
|
78
|
+
function getPercentBarRangeMetrics(categoryKey, isPositive, stackingContext) {
|
|
79
|
+
return isPositive
|
|
80
|
+
? getPositivePercentBarRangeMetrics(categoryKey, stackingContext)
|
|
81
|
+
: getNegativePercentBarRangeMetrics(categoryKey, stackingContext);
|
|
82
|
+
}
|
|
83
|
+
function getPositivePercentBarRangeMetrics(categoryKey, stackingContext) {
|
|
84
|
+
return {
|
|
85
|
+
cumulative: stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0,
|
|
86
|
+
total: stackingContext?.positiveTotalData.get(categoryKey) ?? 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function getNegativePercentBarRangeMetrics(categoryKey, stackingContext) {
|
|
90
|
+
return {
|
|
91
|
+
cumulative: stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0,
|
|
92
|
+
total: stackingContext?.negativeTotalData.get(categoryKey) ?? 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function getStackedBarValueRange(categoryKey, value, stackingContext) {
|
|
96
|
+
if (value >= 0) {
|
|
97
|
+
const cumulative = stackingContext?.positiveCumulativeData.get(categoryKey) ?? 0;
|
|
98
|
+
return {
|
|
99
|
+
start: cumulative,
|
|
100
|
+
end: cumulative + value,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const cumulativeMagnitude = stackingContext?.negativeCumulativeData.get(categoryKey) ?? 0;
|
|
104
|
+
const start = -cumulativeMagnitude;
|
|
105
|
+
return {
|
|
106
|
+
start,
|
|
107
|
+
end: start + value,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function getScaledValueRange(scale, startValue, endValue) {
|
|
111
|
+
const start = scale(startValue) ?? 0;
|
|
112
|
+
const end = scale(endValue) ?? 0;
|
|
113
|
+
return {
|
|
114
|
+
start,
|
|
115
|
+
end,
|
|
116
|
+
min: Math.min(start, end),
|
|
117
|
+
max: Math.max(start, end),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
17
120
|
export class Bar {
|
|
18
121
|
constructor(config) {
|
|
19
122
|
Object.defineProperty(this, "type", {
|
|
@@ -46,6 +149,12 @@ export class Bar {
|
|
|
46
149
|
writable: true,
|
|
47
150
|
value: void 0
|
|
48
151
|
});
|
|
152
|
+
Object.defineProperty(this, "side", {
|
|
153
|
+
enumerable: true,
|
|
154
|
+
configurable: true,
|
|
155
|
+
writable: true,
|
|
156
|
+
value: void 0
|
|
157
|
+
});
|
|
49
158
|
Object.defineProperty(this, "valueLabel", {
|
|
50
159
|
enumerable: true,
|
|
51
160
|
configurable: true,
|
|
@@ -62,6 +171,7 @@ export class Bar {
|
|
|
62
171
|
this.fill = config.fill || '#8884d8';
|
|
63
172
|
this.colorAdapter = config.colorAdapter;
|
|
64
173
|
this.maxBarSize = config.maxBarSize;
|
|
174
|
+
this.side = config.side ?? 'right';
|
|
65
175
|
this.valueLabel = config.valueLabel;
|
|
66
176
|
this.exportHooks = config.exportHooks;
|
|
67
177
|
}
|
|
@@ -71,6 +181,7 @@ export class Bar {
|
|
|
71
181
|
fill: this.fill,
|
|
72
182
|
colorAdapter: this.colorAdapter,
|
|
73
183
|
maxBarSize: this.maxBarSize,
|
|
184
|
+
side: this.side,
|
|
74
185
|
valueLabel: this.valueLabel,
|
|
75
186
|
};
|
|
76
187
|
}
|
|
@@ -81,6 +192,12 @@ export class Bar {
|
|
|
81
192
|
exportHooks: this.exportHooks,
|
|
82
193
|
});
|
|
83
194
|
}
|
|
195
|
+
getRenderedValue(value, orientation = 'vertical') {
|
|
196
|
+
if (orientation === 'horizontal' && this.side === 'left') {
|
|
197
|
+
return -Math.abs(value);
|
|
198
|
+
}
|
|
199
|
+
return value;
|
|
200
|
+
}
|
|
84
201
|
render(plotGroup, data, xKey, x, y, parseValue, xScaleType = 'band', theme, stackingContext, orientation = 'vertical') {
|
|
85
202
|
if (orientation === 'vertical') {
|
|
86
203
|
this.renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext);
|
|
@@ -101,49 +218,13 @@ export class Bar {
|
|
|
101
218
|
renderVertical(plotGroup, data, xKey, x, y, parseValue, xScaleType, stackingContext) {
|
|
102
219
|
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
103
220
|
const mode = stackingContext?.mode ?? 'normal';
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const gap = stackingContext?.gap ?? 0.1;
|
|
112
|
-
const groupWidth = this.maxBarSize
|
|
113
|
-
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
114
|
-
: bandwidth;
|
|
115
|
-
// Calculate total gap space and individual bar width
|
|
116
|
-
const totalGapSpace = groupWidth * gap * (totalSeries - 1);
|
|
117
|
-
const availableWidth = groupWidth - totalGapSpace;
|
|
118
|
-
barWidth = availableWidth / totalSeries;
|
|
119
|
-
const gapSize = totalSeries > 1 ? groupWidth * gap : 0;
|
|
120
|
-
barOffset =
|
|
121
|
-
(bandwidth - groupWidth) / 2 +
|
|
122
|
-
seriesIndex * (barWidth + gapSize);
|
|
123
|
-
}
|
|
124
|
-
else if (mode === 'layer') {
|
|
125
|
-
// Layer mode: each subsequent series has smaller bars
|
|
126
|
-
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
127
|
-
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
128
|
-
const maxWidth = this.maxBarSize
|
|
129
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
130
|
-
: bandwidth;
|
|
131
|
-
// Scale from 100% to a minimum (e.g., 30%) based on series position
|
|
132
|
-
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
133
|
-
barWidth = maxWidth * scaleFactor;
|
|
134
|
-
barOffset = (bandwidth - barWidth) / 2;
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
// Normal and Percent modes: full width stacked bars
|
|
138
|
-
barWidth = this.maxBarSize
|
|
139
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
140
|
-
: bandwidth;
|
|
141
|
-
barOffset = (bandwidth - barWidth) / 2;
|
|
142
|
-
}
|
|
143
|
-
// Get the baseline value from the Y scale's domain
|
|
144
|
-
const yDomain = y.domain();
|
|
145
|
-
const baselineValue = yDomain[0] >= 0 ? Math.max(0, yDomain[0]) : yDomain[0];
|
|
146
|
-
const yBaseline = y(baselineValue) || 0;
|
|
221
|
+
const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
|
|
222
|
+
const getVerticalBounds = (d) => {
|
|
223
|
+
const categoryKey = String(d[xKey]);
|
|
224
|
+
const value = this.getRenderedValue(parseValue(d[this.dataKey]), 'vertical');
|
|
225
|
+
const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
|
|
226
|
+
return getScaledValueRange(y, start, end);
|
|
227
|
+
};
|
|
147
228
|
// Add bar rectangles
|
|
148
229
|
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
149
230
|
plotGroup
|
|
@@ -158,96 +239,24 @@ export class Bar {
|
|
|
158
239
|
? xPos + barOffset
|
|
159
240
|
: xPos - barWidth / 2;
|
|
160
241
|
})
|
|
161
|
-
.attr('y', (d) =>
|
|
162
|
-
const categoryKey = String(d[xKey]);
|
|
163
|
-
const value = parseValue(d[this.dataKey]);
|
|
164
|
-
if (mode === 'none' || mode === 'layer') {
|
|
165
|
-
// No stacking - each bar starts from baseline
|
|
166
|
-
const yPos = y(value) || 0;
|
|
167
|
-
return Math.min(yBaseline, yPos);
|
|
168
|
-
}
|
|
169
|
-
else if (mode === 'percent') {
|
|
170
|
-
// Percent mode: calculate position based on cumulative percentage
|
|
171
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
172
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
173
|
-
const percentCumulative = (cumulative / total) * 100;
|
|
174
|
-
const percentValue = (value / total) * 100;
|
|
175
|
-
return y(percentCumulative + percentValue) || 0;
|
|
176
|
-
}
|
|
177
|
-
else {
|
|
178
|
-
// Normal stacking mode
|
|
179
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
180
|
-
return y(cumulative + value) || 0;
|
|
181
|
-
}
|
|
182
|
-
})
|
|
242
|
+
.attr('y', (d) => getVerticalBounds(d).min)
|
|
183
243
|
.attr('width', barWidth)
|
|
184
244
|
.attr('height', (d) => {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
if (mode === 'none' || mode === 'layer') {
|
|
188
|
-
const yPos = y(value) || 0;
|
|
189
|
-
return Math.abs(yBaseline - yPos);
|
|
190
|
-
}
|
|
191
|
-
else if (mode === 'percent') {
|
|
192
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
193
|
-
const percentValue = (value / total) * 100;
|
|
194
|
-
const yTop = y(percentValue) || 0;
|
|
195
|
-
const yBottom = y(0) || 0;
|
|
196
|
-
return Math.abs(yBottom - yTop);
|
|
197
|
-
}
|
|
198
|
-
else {
|
|
199
|
-
// Normal stacking mode
|
|
200
|
-
const yTop = y(value) || 0;
|
|
201
|
-
return Math.abs(yBaseline - yTop);
|
|
202
|
-
}
|
|
245
|
+
const bounds = getVerticalBounds(d);
|
|
246
|
+
return Math.abs(bounds.max - bounds.min);
|
|
203
247
|
})
|
|
204
248
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
205
249
|
}
|
|
206
250
|
renderHorizontal(plotGroup, data, xKey, x, y, parseValue, yScaleType, stackingContext) {
|
|
207
251
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
208
252
|
const mode = stackingContext?.mode ?? 'normal';
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
const gap = stackingContext?.gap ?? 0.1;
|
|
217
|
-
const groupHeight = this.maxBarSize
|
|
218
|
-
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
219
|
-
: bandwidth;
|
|
220
|
-
// Calculate total gap space and individual bar height
|
|
221
|
-
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
222
|
-
const availableHeight = groupHeight - totalGapSpace;
|
|
223
|
-
barHeight = availableHeight / totalSeries;
|
|
224
|
-
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
225
|
-
barOffset =
|
|
226
|
-
(bandwidth - groupHeight) / 2 +
|
|
227
|
-
seriesIndex * (barHeight + gapSize);
|
|
228
|
-
}
|
|
229
|
-
else if (mode === 'layer') {
|
|
230
|
-
// Layer mode: each subsequent series has smaller bars
|
|
231
|
-
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
232
|
-
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
233
|
-
const maxHeight = this.maxBarSize
|
|
234
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
235
|
-
: bandwidth;
|
|
236
|
-
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
237
|
-
barHeight = maxHeight * scaleFactor;
|
|
238
|
-
barOffset = (bandwidth - barHeight) / 2;
|
|
239
|
-
}
|
|
240
|
-
else {
|
|
241
|
-
// Normal and Percent modes: full height stacked bars
|
|
242
|
-
barHeight = this.maxBarSize
|
|
243
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
244
|
-
: bandwidth;
|
|
245
|
-
barOffset = (bandwidth - barHeight) / 2;
|
|
246
|
-
}
|
|
247
|
-
// Get the baseline value from the scale's domain
|
|
248
|
-
const domain = x.domain();
|
|
249
|
-
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
250
|
-
const xBaseline = x(baselineValue) || 0;
|
|
253
|
+
const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
|
|
254
|
+
const getHorizontalBounds = (d) => {
|
|
255
|
+
const categoryKey = String(d[xKey]);
|
|
256
|
+
const value = this.getRenderedValue(parseValue(d[this.dataKey]), 'horizontal');
|
|
257
|
+
const { start, end } = getBarValueRange(categoryKey, value, stackingContext);
|
|
258
|
+
return getScaledValueRange(x, start, end);
|
|
259
|
+
};
|
|
251
260
|
// Add bar rectangles (horizontal)
|
|
252
261
|
const sanitizedKey = sanitizeForCSS(this.dataKey);
|
|
253
262
|
plotGroup
|
|
@@ -256,25 +265,7 @@ export class Bar {
|
|
|
256
265
|
.join('rect')
|
|
257
266
|
.attr('class', `bar-${sanitizedKey}`)
|
|
258
267
|
.attr('data-index', (_, i) => i)
|
|
259
|
-
.attr('x', (d) =>
|
|
260
|
-
const categoryKey = String(d[xKey]);
|
|
261
|
-
const value = parseValue(d[this.dataKey]);
|
|
262
|
-
if (mode === 'none' || mode === 'layer') {
|
|
263
|
-
const xPos = x(value) || 0;
|
|
264
|
-
return Math.min(xBaseline, xPos);
|
|
265
|
-
}
|
|
266
|
-
else if (mode === 'percent') {
|
|
267
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
268
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
269
|
-
const percentCumulative = (cumulative / total) * 100;
|
|
270
|
-
return x(percentCumulative) || 0;
|
|
271
|
-
}
|
|
272
|
-
else {
|
|
273
|
-
// Normal stacking mode
|
|
274
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
275
|
-
return x(cumulative) || 0;
|
|
276
|
-
}
|
|
277
|
-
})
|
|
268
|
+
.attr('x', (d) => getHorizontalBounds(d).min)
|
|
278
269
|
.attr('y', (d) => {
|
|
279
270
|
const yPos = getScalePosition(y, d[xKey], yScaleType);
|
|
280
271
|
return yScaleType === 'band'
|
|
@@ -282,396 +273,281 @@ export class Bar {
|
|
|
282
273
|
: yPos - barHeight / 2;
|
|
283
274
|
})
|
|
284
275
|
.attr('width', (d) => {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
if (mode === 'none' || mode === 'layer') {
|
|
288
|
-
const xPos = x(value) || 0;
|
|
289
|
-
return Math.abs(xPos - xBaseline);
|
|
290
|
-
}
|
|
291
|
-
else if (mode === 'percent') {
|
|
292
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
293
|
-
const percentValue = (value / total) * 100;
|
|
294
|
-
const xLeft = x(0) || 0;
|
|
295
|
-
const xRight = x(percentValue) || 0;
|
|
296
|
-
return Math.abs(xRight - xLeft);
|
|
297
|
-
}
|
|
298
|
-
else {
|
|
299
|
-
// Normal stacking mode
|
|
300
|
-
const xLeft = x(0) || 0;
|
|
301
|
-
const xRight = x(value) || 0;
|
|
302
|
-
return Math.abs(xRight - xLeft);
|
|
303
|
-
}
|
|
276
|
+
const bounds = getHorizontalBounds(d);
|
|
277
|
+
return Math.abs(bounds.max - bounds.min);
|
|
304
278
|
})
|
|
305
279
|
.attr('height', barHeight)
|
|
306
280
|
.attr('fill', (d, i) => this.colorAdapter ? this.colorAdapter(d, i) : this.fill);
|
|
307
281
|
}
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
282
|
+
resolveValueLabelConfig(theme) {
|
|
283
|
+
const config = this.valueLabel;
|
|
284
|
+
return {
|
|
285
|
+
...this.resolveValueLabelPlacement(config),
|
|
286
|
+
...this.resolveValueLabelStyle(config, theme),
|
|
287
|
+
formatter: config.formatter,
|
|
288
|
+
autoContrastInside: config.color === undefined,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
resolveValueLabelPlacement(config) {
|
|
292
|
+
return {
|
|
293
|
+
position: config.position ?? 'outside',
|
|
294
|
+
insidePosition: config.insidePosition ?? 'top',
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
resolveValueLabelStyle(config, theme) {
|
|
298
|
+
return {
|
|
299
|
+
fontSize: config.fontSize ?? theme.valueLabel.fontSize,
|
|
300
|
+
fontFamily: config.fontFamily ?? theme.valueLabel.fontFamily,
|
|
301
|
+
fontWeight: config.fontWeight ?? theme.valueLabel.fontWeight,
|
|
302
|
+
color: config.color ?? theme.valueLabel.color,
|
|
303
|
+
background: config.background ?? theme.valueLabel.background,
|
|
304
|
+
border: config.border ?? theme.valueLabel.border,
|
|
305
|
+
borderRadius: config.borderRadius ?? theme.valueLabel.borderRadius,
|
|
306
|
+
padding: config.padding ?? theme.valueLabel.padding,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
getValueLabelText(rawValue, data, config) {
|
|
310
|
+
return config.formatter
|
|
311
|
+
? config.formatter(this.dataKey, rawValue, data)
|
|
312
|
+
: String(rawValue);
|
|
313
|
+
}
|
|
314
|
+
getBarColor(data, index) {
|
|
315
|
+
return this.colorAdapter ? this.colorAdapter(data, index) : this.fill;
|
|
316
|
+
}
|
|
317
|
+
measureLabelBox(labelGroup, valueText, config) {
|
|
318
|
+
const tempText = labelGroup
|
|
319
|
+
.append('text')
|
|
320
|
+
.style('font-size', `${config.fontSize}px`)
|
|
321
|
+
.style('font-family', config.fontFamily)
|
|
322
|
+
.style('font-weight', config.fontWeight)
|
|
323
|
+
.text(valueText);
|
|
324
|
+
const textBox = tempText.node().getBBox();
|
|
325
|
+
tempText.remove();
|
|
326
|
+
return {
|
|
327
|
+
width: textBox.width + config.padding * 2,
|
|
328
|
+
height: textBox.height + config.padding * 2,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
getLabelColor(config, barColor) {
|
|
332
|
+
if (config.position === 'inside' && config.autoContrastInside) {
|
|
333
|
+
return getContrastTextColor(barColor);
|
|
328
334
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
return config.color;
|
|
336
|
+
}
|
|
337
|
+
appendValueLabel(labelGroup, valueText, placement, labelBox, config, labelColor) {
|
|
338
|
+
const group = labelGroup.append('g');
|
|
339
|
+
if (config.position === 'outside') {
|
|
340
|
+
group
|
|
341
|
+
.append('rect')
|
|
342
|
+
.attr('x', placement.x - labelBox.width / 2)
|
|
343
|
+
.attr('y', placement.y - labelBox.height / 2)
|
|
344
|
+
.attr('width', labelBox.width)
|
|
345
|
+
.attr('height', labelBox.height)
|
|
346
|
+
.attr('rx', config.borderRadius)
|
|
347
|
+
.attr('ry', config.borderRadius)
|
|
348
|
+
.attr('fill', config.background)
|
|
349
|
+
.attr('stroke', config.border)
|
|
350
|
+
.attr('stroke-width', 1);
|
|
338
351
|
}
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
352
|
+
group
|
|
353
|
+
.append('text')
|
|
354
|
+
.attr('x', placement.x)
|
|
355
|
+
.attr('y', placement.y)
|
|
356
|
+
.attr('text-anchor', 'middle')
|
|
357
|
+
.attr('dominant-baseline', 'central')
|
|
358
|
+
.style('font-size', `${config.fontSize}px`)
|
|
359
|
+
.style('font-family', config.fontFamily)
|
|
360
|
+
.style('font-weight', config.fontWeight)
|
|
361
|
+
.style('fill', labelColor)
|
|
362
|
+
.style('pointer-events', 'none')
|
|
363
|
+
.text(valueText);
|
|
364
|
+
}
|
|
365
|
+
getVerticalLabelPlacement(input) {
|
|
366
|
+
if (input.position === 'outside') {
|
|
367
|
+
return this.getVerticalOutsideLabelPlacement(input);
|
|
344
368
|
}
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
const
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
369
|
+
if (input.mode === 'layer' && input.insidePosition === 'bottom') {
|
|
370
|
+
return {
|
|
371
|
+
x: input.x,
|
|
372
|
+
y: (input.barTop + input.barBottom) / 2,
|
|
373
|
+
shouldRender: false,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const { inset, minPadding } = getLabelSpacing(input.mode);
|
|
377
|
+
const y = this.getVerticalInsideLabelY(input.barTop, input.barBottom, input.labelBox.height, input.insidePosition, inset);
|
|
378
|
+
return {
|
|
379
|
+
x: input.x,
|
|
380
|
+
y,
|
|
381
|
+
shouldRender: input.labelBox.height + minPadding <= input.barHeight,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
getVerticalOutsideLabelPlacement(input) {
|
|
385
|
+
const y = input.isNegative
|
|
386
|
+
? input.barBottom + input.labelBox.height / 2 + LABEL_OUTSIDE_OFFSET
|
|
387
|
+
: input.barTop - input.labelBox.height / 2 - LABEL_OUTSIDE_OFFSET;
|
|
388
|
+
const shouldRender = input.isNegative
|
|
389
|
+
? y + input.labelBox.height / 2 <= input.plotBottom
|
|
390
|
+
: y - input.labelBox.height / 2 >= input.plotTop;
|
|
391
|
+
return {
|
|
392
|
+
x: input.x,
|
|
393
|
+
y,
|
|
394
|
+
shouldRender,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
getVerticalInsideLabelY(barTop, barBottom, labelHeight, insidePosition, inset) {
|
|
398
|
+
switch (insidePosition) {
|
|
399
|
+
case 'top':
|
|
400
|
+
return barTop + labelHeight / 2 + inset;
|
|
401
|
+
case 'middle':
|
|
402
|
+
return (barTop + barBottom) / 2;
|
|
403
|
+
case 'bottom':
|
|
404
|
+
return barBottom - labelHeight / 2 - inset;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
getHorizontalLabelPlacement(input) {
|
|
408
|
+
if (input.position === 'outside') {
|
|
409
|
+
return this.getHorizontalOutsideLabelPlacement(input);
|
|
410
|
+
}
|
|
411
|
+
if (input.mode === 'layer' && input.insidePosition === 'bottom') {
|
|
412
|
+
return {
|
|
413
|
+
x: (input.barLeft + input.barRight) / 2,
|
|
414
|
+
y: input.y,
|
|
415
|
+
shouldRender: false,
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
const { inset, minPadding } = getLabelSpacing(input.mode);
|
|
419
|
+
const x = this.getHorizontalInsideLabelX(input.barLeft, input.barRight, input.labelBox.width, input.isNegative, input.insidePosition, inset);
|
|
420
|
+
const fitsBar = input.labelBox.width + minPadding <= input.barWidth;
|
|
421
|
+
const withinBounds = input.insidePosition === 'middle' ||
|
|
422
|
+
this.isHorizontalLabelWithinBounds(x, input.labelBox.width, input.barLeft, input.barRight);
|
|
423
|
+
return {
|
|
424
|
+
x,
|
|
425
|
+
y: input.y,
|
|
426
|
+
shouldRender: fitsBar && withinBounds,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
getHorizontalOutsideLabelPlacement(input) {
|
|
430
|
+
const x = input.isNegative
|
|
431
|
+
? input.barLeft - input.labelBox.width / 2 - LABEL_OUTSIDE_OFFSET
|
|
432
|
+
: input.barRight + input.labelBox.width / 2 + LABEL_OUTSIDE_OFFSET;
|
|
433
|
+
const shouldRender = input.isNegative
|
|
434
|
+
? x - input.labelBox.width / 2 >= input.plotLeft
|
|
435
|
+
: x + input.labelBox.width / 2 <= input.plotRight;
|
|
436
|
+
return {
|
|
437
|
+
x,
|
|
438
|
+
y: input.y,
|
|
439
|
+
shouldRender,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
getHorizontalInsideLabelX(barLeft, barRight, labelWidth, isNegative, insidePosition, inset) {
|
|
443
|
+
switch (insidePosition) {
|
|
444
|
+
case 'top':
|
|
445
|
+
return isNegative
|
|
446
|
+
? barRight - labelWidth / 2 - inset
|
|
447
|
+
: barLeft + labelWidth / 2 + inset;
|
|
448
|
+
case 'middle':
|
|
449
|
+
return (barLeft + barRight) / 2;
|
|
450
|
+
case 'bottom':
|
|
451
|
+
return isNegative
|
|
452
|
+
? barLeft + labelWidth / 2 + inset
|
|
453
|
+
: barRight - labelWidth / 2 - inset;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
isHorizontalLabelWithinBounds(labelX, labelWidth, barLeft, barRight) {
|
|
457
|
+
const labelLeft = labelX - labelWidth / 2;
|
|
458
|
+
const labelRight = labelX + labelWidth / 2;
|
|
459
|
+
return labelLeft >= barLeft + 1 && labelRight <= barRight - 1;
|
|
460
|
+
}
|
|
461
|
+
renderVerticalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, xScaleType, stackingContext, config, barWidth, barOffset, mode, plotTop, plotBottom) {
|
|
462
|
+
const categoryKey = String(dataItem[xKey]);
|
|
463
|
+
const rawValue = parseValue(dataItem[this.dataKey]);
|
|
464
|
+
const renderedValue = this.getRenderedValue(rawValue, 'vertical');
|
|
465
|
+
const valueText = this.getValueLabelText(rawValue, dataItem, config);
|
|
466
|
+
const xPos = getScalePosition(x, dataItem[xKey], xScaleType);
|
|
467
|
+
const barColor = this.getBarColor(dataItem, index);
|
|
468
|
+
const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
|
|
469
|
+
const bounds = getScaledValueRange(y, start, end);
|
|
470
|
+
const barTop = bounds.min;
|
|
471
|
+
const barBottom = bounds.max;
|
|
472
|
+
const barHeight = Math.abs(barBottom - barTop);
|
|
473
|
+
const barCenterX = xPos +
|
|
474
|
+
(xScaleType === 'band' ? barOffset : -barWidth / 2) +
|
|
475
|
+
barWidth / 2;
|
|
476
|
+
const labelBox = this.measureLabelBox(labelGroup, valueText, config);
|
|
477
|
+
const placement = this.getVerticalLabelPlacement({
|
|
478
|
+
x: barCenterX,
|
|
479
|
+
barTop,
|
|
480
|
+
barBottom,
|
|
481
|
+
barHeight,
|
|
482
|
+
labelBox,
|
|
483
|
+
isNegative: renderedValue < 0,
|
|
484
|
+
mode,
|
|
485
|
+
position: config.position,
|
|
486
|
+
insidePosition: config.insidePosition,
|
|
487
|
+
plotTop,
|
|
488
|
+
plotBottom,
|
|
489
|
+
});
|
|
490
|
+
if (!placement.shouldRender) {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.appendValueLabel(labelGroup, valueText, placement, labelBox, config, this.getLabelColor(config, barColor));
|
|
494
|
+
}
|
|
495
|
+
renderHorizontalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, yScaleType, stackingContext, config, barHeight, barOffset, mode, plotLeft, plotRight) {
|
|
496
|
+
const categoryKey = String(dataItem[xKey]);
|
|
497
|
+
const rawValue = parseValue(dataItem[this.dataKey]);
|
|
498
|
+
const renderedValue = this.getRenderedValue(rawValue, 'horizontal');
|
|
499
|
+
const valueText = this.getValueLabelText(rawValue, dataItem, config);
|
|
500
|
+
const yPos = getScalePosition(y, dataItem[xKey], yScaleType);
|
|
501
|
+
const barColor = this.getBarColor(dataItem, index);
|
|
502
|
+
const { start, end } = getBarValueRange(categoryKey, renderedValue, stackingContext);
|
|
503
|
+
const bounds = getScaledValueRange(x, start, end);
|
|
504
|
+
const barLeft = bounds.min;
|
|
505
|
+
const barRight = bounds.max;
|
|
506
|
+
const barWidth = Math.abs(barRight - barLeft);
|
|
507
|
+
const barCenterY = yPos +
|
|
508
|
+
(yScaleType === 'band' ? barOffset : -barHeight / 2) +
|
|
509
|
+
barHeight / 2;
|
|
510
|
+
const labelBox = this.measureLabelBox(labelGroup, valueText, config);
|
|
511
|
+
const placement = this.getHorizontalLabelPlacement({
|
|
512
|
+
y: barCenterY,
|
|
513
|
+
barLeft,
|
|
514
|
+
barRight,
|
|
515
|
+
barWidth,
|
|
516
|
+
labelBox,
|
|
517
|
+
isNegative: renderedValue < 0,
|
|
518
|
+
mode,
|
|
519
|
+
position: config.position,
|
|
520
|
+
insidePosition: config.insidePosition,
|
|
521
|
+
plotLeft,
|
|
522
|
+
plotRight,
|
|
523
|
+
});
|
|
524
|
+
if (!placement.shouldRender) {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
this.appendValueLabel(labelGroup, valueText, placement, labelBox, config, this.getLabelColor(config, barColor));
|
|
528
|
+
}
|
|
529
|
+
renderVerticalValueLabels(plotGroup, data, xKey, x, y, parseValue, xScaleType, theme, stackingContext) {
|
|
530
|
+
const bandwidth = x.bandwidth ? x.bandwidth() : 20;
|
|
531
|
+
const mode = stackingContext?.mode ?? 'normal';
|
|
532
|
+
const { thickness: barWidth, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
|
|
533
|
+
const config = this.resolveValueLabelConfig(theme);
|
|
534
|
+
const plotTop = Math.min(...y.range());
|
|
535
|
+
const plotBottom = Math.max(...y.range());
|
|
359
536
|
const labelGroup = plotGroup
|
|
360
537
|
.append('g')
|
|
361
538
|
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
362
|
-
data.forEach((
|
|
363
|
-
const categoryKey = String(d[xKey]);
|
|
364
|
-
const value = parseValue(d[this.dataKey]);
|
|
365
|
-
const valueText = String(value);
|
|
366
|
-
const xPos = getScalePosition(x, d[xKey], xScaleType);
|
|
367
|
-
const barColor = this.colorAdapter
|
|
368
|
-
? this.colorAdapter(d, i)
|
|
369
|
-
: this.fill;
|
|
370
|
-
// Calculate bar position based on stacking mode
|
|
371
|
-
let barTop;
|
|
372
|
-
let barBottom;
|
|
373
|
-
if (mode === 'none' || mode === 'layer') {
|
|
374
|
-
const yPos = y(value) || 0;
|
|
375
|
-
barTop = Math.min(yBaseline, yPos);
|
|
376
|
-
barBottom = Math.max(yBaseline, yPos);
|
|
377
|
-
}
|
|
378
|
-
else if (mode === 'percent') {
|
|
379
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
380
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
381
|
-
const percentCumulative = (cumulative / total) * 100;
|
|
382
|
-
const percentValue = (value / total) * 100;
|
|
383
|
-
barTop = y(percentCumulative + percentValue) || 0;
|
|
384
|
-
barBottom = y(percentCumulative) || 0;
|
|
385
|
-
}
|
|
386
|
-
else {
|
|
387
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
388
|
-
barTop = y(cumulative + value) || 0;
|
|
389
|
-
barBottom = y(cumulative) || 0;
|
|
390
|
-
}
|
|
391
|
-
const barHeight = Math.abs(barBottom - barTop);
|
|
392
|
-
const barCenterX = xPos +
|
|
393
|
-
(xScaleType === 'band' ? barOffset : -barWidth / 2) +
|
|
394
|
-
barWidth / 2;
|
|
395
|
-
// Create temporary text to measure dimensions
|
|
396
|
-
const tempText = labelGroup
|
|
397
|
-
.append('text')
|
|
398
|
-
.style('font-size', `${fontSize}px`)
|
|
399
|
-
.style('font-family', fontFamily)
|
|
400
|
-
.style('font-weight', fontWeight)
|
|
401
|
-
.text(valueText);
|
|
402
|
-
const textBBox = tempText.node().getBBox();
|
|
403
|
-
const boxWidth = textBBox.width + padding * 2;
|
|
404
|
-
const boxHeight = textBBox.height + padding * 2;
|
|
405
|
-
const labelX = barCenterX;
|
|
406
|
-
let labelY = (barTop + barBottom) / 2; // Default to middle
|
|
407
|
-
let shouldRender = true;
|
|
408
|
-
if (position === 'outside') {
|
|
409
|
-
// Place above the bar
|
|
410
|
-
labelY = barTop - boxHeight / 2 - 4;
|
|
411
|
-
// Check if it fits (not going above plot area)
|
|
412
|
-
const plotTop = y.range()[1];
|
|
413
|
-
if (labelY - boxHeight / 2 < plotTop) {
|
|
414
|
-
shouldRender = false;
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
else {
|
|
418
|
-
if (mode === 'layer' && insidePosition === 'bottom') {
|
|
419
|
-
// Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
|
|
420
|
-
shouldRender = false;
|
|
421
|
-
}
|
|
422
|
-
else {
|
|
423
|
-
const { inset, minPadding } = getLabelSpacing(mode);
|
|
424
|
-
switch (insidePosition) {
|
|
425
|
-
case 'top':
|
|
426
|
-
labelY = barTop + boxHeight / 2 + inset;
|
|
427
|
-
break;
|
|
428
|
-
case 'middle':
|
|
429
|
-
labelY = (barTop + barBottom) / 2;
|
|
430
|
-
break;
|
|
431
|
-
case 'bottom':
|
|
432
|
-
labelY = barBottom - boxHeight / 2 - inset;
|
|
433
|
-
break;
|
|
434
|
-
}
|
|
435
|
-
// Check if it fits inside the bar
|
|
436
|
-
if (boxHeight + minPadding > barHeight) {
|
|
437
|
-
shouldRender = false;
|
|
438
|
-
}
|
|
439
|
-
// In layer mode, check the label fits in the visible gap
|
|
440
|
-
// above the next layer's bar top
|
|
441
|
-
if (shouldRender &&
|
|
442
|
-
mode === 'layer' &&
|
|
443
|
-
insidePosition === 'top' &&
|
|
444
|
-
stackingContext?.nextLayerData) {
|
|
445
|
-
const nextValue = stackingContext.nextLayerData.get(categoryKey);
|
|
446
|
-
if (nextValue !== undefined) {
|
|
447
|
-
const nextBarTop = y(nextValue) || 0;
|
|
448
|
-
const labelBottom = labelY + boxHeight / 2;
|
|
449
|
-
if (labelBottom + LAYER_LABEL_GAP > nextBarTop) {
|
|
450
|
-
shouldRender = false;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
tempText.remove();
|
|
457
|
-
if (shouldRender) {
|
|
458
|
-
const labelColor = position === 'inside' && config.color === undefined
|
|
459
|
-
? getContrastTextColor(barColor)
|
|
460
|
-
: defaultLabelColor;
|
|
461
|
-
const group = labelGroup.append('g');
|
|
462
|
-
if (position === 'outside') {
|
|
463
|
-
// Draw rounded rectangle background
|
|
464
|
-
group
|
|
465
|
-
.append('rect')
|
|
466
|
-
.attr('x', labelX - boxWidth / 2)
|
|
467
|
-
.attr('y', labelY - boxHeight / 2)
|
|
468
|
-
.attr('width', boxWidth)
|
|
469
|
-
.attr('height', boxHeight)
|
|
470
|
-
.attr('rx', borderRadius)
|
|
471
|
-
.attr('ry', borderRadius)
|
|
472
|
-
.attr('fill', background)
|
|
473
|
-
.attr('stroke', border)
|
|
474
|
-
.attr('stroke-width', 1);
|
|
475
|
-
}
|
|
476
|
-
// Draw text
|
|
477
|
-
group
|
|
478
|
-
.append('text')
|
|
479
|
-
.attr('x', labelX)
|
|
480
|
-
.attr('y', labelY)
|
|
481
|
-
.attr('text-anchor', 'middle')
|
|
482
|
-
.attr('dominant-baseline', 'central')
|
|
483
|
-
.style('font-size', `${fontSize}px`)
|
|
484
|
-
.style('font-family', fontFamily)
|
|
485
|
-
.style('font-weight', fontWeight)
|
|
486
|
-
.style('fill', labelColor)
|
|
487
|
-
.style('pointer-events', 'none')
|
|
488
|
-
.text(valueText);
|
|
489
|
-
}
|
|
490
|
-
});
|
|
539
|
+
data.forEach((dataItem, index) => this.renderVerticalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, xScaleType, stackingContext, config, barWidth, barOffset, mode, plotTop, plotBottom));
|
|
491
540
|
}
|
|
492
541
|
renderHorizontalValueLabels(plotGroup, data, xKey, x, y, parseValue, yScaleType, theme, stackingContext) {
|
|
493
542
|
const bandwidth = y.bandwidth ? y.bandwidth() : 20;
|
|
494
543
|
const mode = stackingContext?.mode ?? 'normal';
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
500
|
-
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
501
|
-
const gap = stackingContext?.gap ?? 0.1;
|
|
502
|
-
const groupHeight = this.maxBarSize
|
|
503
|
-
? Math.min(bandwidth, this.maxBarSize * totalSeries)
|
|
504
|
-
: bandwidth;
|
|
505
|
-
const totalGapSpace = groupHeight * gap * (totalSeries - 1);
|
|
506
|
-
const availableHeight = groupHeight - totalGapSpace;
|
|
507
|
-
barHeight = availableHeight / totalSeries;
|
|
508
|
-
const gapSize = totalSeries > 1 ? groupHeight * gap : 0;
|
|
509
|
-
barOffset =
|
|
510
|
-
(bandwidth - groupHeight) / 2 +
|
|
511
|
-
seriesIndex * (barHeight + gapSize);
|
|
512
|
-
}
|
|
513
|
-
else if (mode === 'layer') {
|
|
514
|
-
const totalSeries = stackingContext?.totalSeries ?? 1;
|
|
515
|
-
const seriesIndex = stackingContext?.seriesIndex ?? 0;
|
|
516
|
-
const maxHeight = this.maxBarSize
|
|
517
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
518
|
-
: bandwidth;
|
|
519
|
-
const scaleFactor = 1 - (seriesIndex / totalSeries) * 0.7;
|
|
520
|
-
barHeight = maxHeight * scaleFactor;
|
|
521
|
-
barOffset = (bandwidth - barHeight) / 2;
|
|
522
|
-
}
|
|
523
|
-
else {
|
|
524
|
-
barHeight = this.maxBarSize
|
|
525
|
-
? Math.min(bandwidth, this.maxBarSize)
|
|
526
|
-
: bandwidth;
|
|
527
|
-
barOffset = (bandwidth - barHeight) / 2;
|
|
528
|
-
}
|
|
529
|
-
const domain = x.domain();
|
|
530
|
-
const baselineValue = domain[0] >= 0 ? Math.max(0, domain[0]) : domain[0];
|
|
531
|
-
const xBaseline = x(baselineValue) || 0;
|
|
532
|
-
const config = this.valueLabel;
|
|
533
|
-
const position = config.position || 'outside';
|
|
534
|
-
const insidePosition = config.insidePosition || 'top';
|
|
535
|
-
const fontSize = config.fontSize ?? theme.valueLabel.fontSize;
|
|
536
|
-
const fontFamily = config.fontFamily ?? theme.valueLabel.fontFamily;
|
|
537
|
-
const fontWeight = config.fontWeight ?? theme.valueLabel.fontWeight;
|
|
538
|
-
const defaultLabelColor = config.color ?? theme.valueLabel.color;
|
|
539
|
-
const background = config.background ?? theme.valueLabel.background;
|
|
540
|
-
const border = config.border ?? theme.valueLabel.border;
|
|
541
|
-
const borderRadius = config.borderRadius ?? theme.valueLabel.borderRadius;
|
|
542
|
-
const padding = config.padding ?? theme.valueLabel.padding;
|
|
544
|
+
const { thickness: barHeight, offset: barOffset } = getBarSlotLayout(bandwidth, mode, this.maxBarSize, stackingContext?.totalSeries ?? 1, stackingContext?.seriesIndex ?? 0, stackingContext?.gap ?? 0.1);
|
|
545
|
+
const config = this.resolveValueLabelConfig(theme);
|
|
546
|
+
const plotLeft = Math.min(...x.range());
|
|
547
|
+
const plotRight = Math.max(...x.range());
|
|
543
548
|
const labelGroup = plotGroup
|
|
544
549
|
.append('g')
|
|
545
550
|
.attr('class', `bar-value-labels-${sanitizeForCSS(this.dataKey)}`);
|
|
546
|
-
data.forEach((
|
|
547
|
-
const categoryKey = String(d[xKey]);
|
|
548
|
-
const value = parseValue(d[this.dataKey]);
|
|
549
|
-
const valueText = String(value);
|
|
550
|
-
const yPos = getScalePosition(y, d[xKey], yScaleType);
|
|
551
|
-
const barColor = this.colorAdapter
|
|
552
|
-
? this.colorAdapter(d, i)
|
|
553
|
-
: this.fill;
|
|
554
|
-
// Calculate bar position based on stacking mode
|
|
555
|
-
let barLeft;
|
|
556
|
-
let barRight;
|
|
557
|
-
if (mode === 'none' || mode === 'layer') {
|
|
558
|
-
const xPos = x(value) || 0;
|
|
559
|
-
barLeft = Math.min(xBaseline, xPos);
|
|
560
|
-
barRight = Math.max(xBaseline, xPos);
|
|
561
|
-
}
|
|
562
|
-
else if (mode === 'percent') {
|
|
563
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
564
|
-
const total = stackingContext?.totalData.get(categoryKey) ?? 1;
|
|
565
|
-
const percentCumulative = (cumulative / total) * 100;
|
|
566
|
-
const percentValue = (value / total) * 100;
|
|
567
|
-
barLeft = x(percentCumulative) || 0;
|
|
568
|
-
barRight = x(percentCumulative + percentValue) || 0;
|
|
569
|
-
}
|
|
570
|
-
else {
|
|
571
|
-
const cumulative = stackingContext?.cumulativeData.get(categoryKey) ?? 0;
|
|
572
|
-
barLeft = x(cumulative) || 0;
|
|
573
|
-
barRight = x(cumulative + value) || 0;
|
|
574
|
-
}
|
|
575
|
-
const barWidth = Math.abs(barRight - barLeft);
|
|
576
|
-
const barCenterY = yPos +
|
|
577
|
-
(yScaleType === 'band' ? barOffset : -barHeight / 2) +
|
|
578
|
-
barHeight / 2;
|
|
579
|
-
// Create temporary text to measure dimensions
|
|
580
|
-
const tempText = labelGroup
|
|
581
|
-
.append('text')
|
|
582
|
-
.style('font-size', `${fontSize}px`)
|
|
583
|
-
.style('font-family', fontFamily)
|
|
584
|
-
.style('font-weight', fontWeight)
|
|
585
|
-
.text(valueText);
|
|
586
|
-
const textBBox = tempText.node().getBBox();
|
|
587
|
-
const boxWidth = textBBox.width + padding * 2;
|
|
588
|
-
const boxHeight = textBBox.height + padding * 2;
|
|
589
|
-
let labelX = (barLeft + barRight) / 2; // Default to middle
|
|
590
|
-
const labelY = barCenterY;
|
|
591
|
-
let shouldRender = true;
|
|
592
|
-
if (position === 'outside') {
|
|
593
|
-
// Place to the right of the bar
|
|
594
|
-
labelX = barRight + boxWidth / 2 + 4;
|
|
595
|
-
// Check if it fits (not going beyond plot area)
|
|
596
|
-
const plotRight = x.range()[1];
|
|
597
|
-
if (labelX + boxWidth / 2 > plotRight) {
|
|
598
|
-
shouldRender = false;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
else {
|
|
602
|
-
// Map top/middle/bottom to start/middle/end for horizontal
|
|
603
|
-
if (mode === 'layer' && insidePosition === 'bottom') {
|
|
604
|
-
// Bottom labels in layer mode are visually ambiguous and often hidden by overlap.
|
|
605
|
-
shouldRender = false;
|
|
606
|
-
}
|
|
607
|
-
else {
|
|
608
|
-
const { inset, minPadding } = getLabelSpacing(mode);
|
|
609
|
-
switch (insidePosition) {
|
|
610
|
-
case 'top': // start of bar (left side)
|
|
611
|
-
labelX = barLeft + boxWidth / 2 + inset;
|
|
612
|
-
break;
|
|
613
|
-
case 'middle':
|
|
614
|
-
labelX = (barLeft + barRight) / 2;
|
|
615
|
-
break;
|
|
616
|
-
case 'bottom': // end of bar (right side)
|
|
617
|
-
labelX = barRight - boxWidth / 2 - inset;
|
|
618
|
-
break;
|
|
619
|
-
}
|
|
620
|
-
// Check if it fits inside the bar
|
|
621
|
-
if (boxWidth + minPadding > barWidth) {
|
|
622
|
-
shouldRender = false;
|
|
623
|
-
}
|
|
624
|
-
// In layer mode, check the label fits in the visible gap
|
|
625
|
-
// before the next layer's bar end
|
|
626
|
-
if (shouldRender &&
|
|
627
|
-
mode === 'layer' &&
|
|
628
|
-
insidePosition === 'top' &&
|
|
629
|
-
stackingContext?.nextLayerData) {
|
|
630
|
-
const nextValue = stackingContext.nextLayerData.get(categoryKey);
|
|
631
|
-
if (nextValue !== undefined) {
|
|
632
|
-
const nextBarRight = x(nextValue) || 0;
|
|
633
|
-
const labelRight = labelX + boxWidth / 2;
|
|
634
|
-
if (labelRight + LAYER_LABEL_GAP > nextBarRight) {
|
|
635
|
-
shouldRender = false;
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
tempText.remove();
|
|
642
|
-
if (shouldRender) {
|
|
643
|
-
const labelColor = position === 'inside' && config.color === undefined
|
|
644
|
-
? getContrastTextColor(barColor)
|
|
645
|
-
: defaultLabelColor;
|
|
646
|
-
const group = labelGroup.append('g');
|
|
647
|
-
if (position === 'outside') {
|
|
648
|
-
// Draw rounded rectangle background
|
|
649
|
-
group
|
|
650
|
-
.append('rect')
|
|
651
|
-
.attr('x', labelX - boxWidth / 2)
|
|
652
|
-
.attr('y', labelY - boxHeight / 2)
|
|
653
|
-
.attr('width', boxWidth)
|
|
654
|
-
.attr('height', boxHeight)
|
|
655
|
-
.attr('rx', borderRadius)
|
|
656
|
-
.attr('ry', borderRadius)
|
|
657
|
-
.attr('fill', background)
|
|
658
|
-
.attr('stroke', border)
|
|
659
|
-
.attr('stroke-width', 1);
|
|
660
|
-
}
|
|
661
|
-
// Draw text
|
|
662
|
-
group
|
|
663
|
-
.append('text')
|
|
664
|
-
.attr('x', labelX)
|
|
665
|
-
.attr('y', labelY)
|
|
666
|
-
.attr('text-anchor', 'middle')
|
|
667
|
-
.attr('dominant-baseline', 'central')
|
|
668
|
-
.style('font-size', `${fontSize}px`)
|
|
669
|
-
.style('font-family', fontFamily)
|
|
670
|
-
.style('font-weight', fontWeight)
|
|
671
|
-
.style('fill', labelColor)
|
|
672
|
-
.style('pointer-events', 'none')
|
|
673
|
-
.text(valueText);
|
|
674
|
-
}
|
|
675
|
-
});
|
|
551
|
+
data.forEach((dataItem, index) => this.renderHorizontalValueLabel(labelGroup, dataItem, index, xKey, x, y, parseValue, yScaleType, stackingContext, config, barHeight, barOffset, mode, plotLeft, plotRight));
|
|
676
552
|
}
|
|
677
553
|
}
|