Skip to content

Commit 3f7b412

Browse files
committed
Improvement - VueUiStackbar - Add support for negative values
1 parent 35d248f commit 3f7b412

File tree

1 file changed

+123
-57
lines changed

1 file changed

+123
-57
lines changed

src/components/vue-ui-stackbar.vue

Lines changed: 123 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup>
2-
import { ref, computed, onMounted } from "vue";
2+
import { ref, computed, onMounted, nextTick } from "vue";
33
import { useConfig } from "../useConfig";
44
import {
55
adaptColorToBackground,
@@ -78,6 +78,7 @@ const chartSlicer = ref(null);
7878
const slicerStep = ref(0);
7979
const isFullscreen = ref(false);
8080
const trapIndex = ref(null);
81+
const isLoaded = ref(false);
8182
8283
onMounted(() => {
8384
if(objectIsEmpty(props.dataset)) {
@@ -101,6 +102,9 @@ onMounted(() => {
101102
})
102103
})
103104
}
105+
setTimeout(() => {
106+
isLoaded.value = true;
107+
}, 10)
104108
})
105109
106110
const FINAL_CONFIG = computed(() => {
@@ -150,10 +154,11 @@ const customPalette = computed(() => {
150154
});
151155
152156
const resizeObserver = ref(null);
153-
157+
const to = ref(null)
154158
onMounted(() => {
155159
if (FINAL_CONFIG.value.responsive) {
156160
const handleResize = throttle(() => {
161+
isLoaded.value = false; // unset rect transitions as it looks janky during resizing process
157162
const { width, height } = useResponsive({
158163
chart: stackbarChart.value,
159164
title: FINAL_CONFIG.value.style.chart.title.text ? chartTitle.value : null,
@@ -162,6 +167,10 @@ onMounted(() => {
162167
});
163168
defaultSizes.value.width = width;
164169
defaultSizes.value.height = height;
170+
clearTimeout(to.value);
171+
to.value = setTimeout(() => {
172+
isLoaded.value = true;
173+
},10)
165174
});
166175
167176
resizeObserver.value = new ResizeObserver(handleResize);
@@ -189,7 +198,7 @@ const drawingArea = computed(() => {
189198
bottom,
190199
left,
191200
width,
192-
height,
201+
height: height < 0 ? 0 : height,
193202
}
194203
});
195204
@@ -198,6 +207,12 @@ const unmutableDataset = computed(() => {
198207
const color = convertColorToHex(ds.color) || customPalette.value[i] || palette[i] || palette[i % palette.length];
199208
return {
200209
...ds,
210+
// In distributed mode, all values are converted to positive
211+
series: JSON.parse(JSON.stringify(ds.series)).map(v => {
212+
return FINAL_CONFIG.value.style.chart.bars.distributed ? Math.abs(v) : v
213+
}),
214+
// Store signs to manage display of neg values in distributed mode
215+
signedSeries: ds.series.map(v => v >= 0 ? 1 : -1),
201216
absoluteIndex: i,
202217
id: createUid(),
203218
color
@@ -209,7 +224,6 @@ const maxSeries = computed(() => {
209224
return Math.max(...unmutableDataset.value.filter(ds => !segregated.value.includes(ds.id)).map(ds => ds.series.length))
210225
});
211226
212-
213227
const slicer = ref({
214228
start: 0,
215229
end: Math.max(...props.dataset.map(ds => ds.series.length))
@@ -232,12 +246,33 @@ const datasetTotals = computed(() => {
232246
return sumSeries(unmutableDataset.value.filter(ds => !segregated.value.includes(ds.id))).slice(slicer.value.start, slicer.value.end);
233247
});
234248
249+
const datasetSignedTotals = computed(() => {
250+
const src = unmutableDataset.value.filter(ds => !segregated.value.includes(ds.id))
251+
return {
252+
positive: sumSeries(src.map(s => {
253+
return {
254+
...s,
255+
series: s.series.slice(slicer.value.start, slicer.value.end).map(v => v >= 0 ? v : 0)
256+
}
257+
})),
258+
negative: sumSeries(src.map(s => {
259+
return {
260+
...s,
261+
series: s.series.slice(slicer.value.start, slicer.value.end).map(v => v < 0 ? v : 0)
262+
}
263+
}))
264+
}
265+
});
266+
235267
const yLabels = computed(() => {
236-
const MAX = Math.max(...datasetTotals.value);
237-
const scale = calculateNiceScale(0, MAX, FINAL_CONFIG.value.style.chart.grid.scale.ticks);
268+
const MAX = Math.max(...datasetSignedTotals.value.positive);
269+
const workingMin = Math.min(...datasetSignedTotals.value.negative);
270+
const MIN = [-Infinity, Infinity, NaN, undefined, null].includes(workingMin) ? 0 : workingMin;
271+
const scale = calculateNiceScale((MIN > 0 ? 0 : MIN), MAX < 0 ? 0 : MAX, FINAL_CONFIG.value.style.chart.grid.scale.ticks);
238272
return scale.ticks.map(t => {
239273
return {
240-
y: drawingArea.value.bottom - (drawingArea.value.height * (t / scale.max)),
274+
zero: drawingArea.value.bottom - (drawingArea.value.height * ((Math.abs(scale.min)) / (scale.max + Math.abs(scale.min)))),
275+
y: drawingArea.value.bottom - (drawingArea.value.height * ((t + Math.abs(scale.min)) / ((scale.max) + Math.abs(scale.min)))),
241276
x: drawingArea.value.left - 8,
242277
value: t
243278
}
@@ -251,48 +286,78 @@ const timeLabels = computed(() => {
251286
252287
const formattedDataset = computed(() => {
253288
if (!isDataset.value) return [];
289+
254290
let cumulativeY = Array(maxSeries.value).fill(0);
291+
let cumulativeNegY = Array(maxSeries.value).fill(0);
292+
293+
const premax = Math.max(...datasetSignedTotals.value.positive) || 0;
294+
const workingMin = Math.min(...datasetSignedTotals.value.negative);
295+
const premin = [-Infinity, Infinity, NaN, undefined, null].includes(workingMin) ? 0 : workingMin;
296+
297+
const scale = calculateNiceScale((premin > 0 ? 0 : premin), premax < 0 ? 0 : premax, FINAL_CONFIG.value.style.chart.grid.scale.ticks);
298+
const { min: MIN, max: MAX } = scale;
299+
300+
const maxTotal = (MAX + (MIN >= 0 ? 0 : Math.abs(MIN))) || 1
301+
255302
const totalHeight = drawingArea.value.height;
256-
const MAX = Math.max(...datasetTotals.value);
257-
const scale = calculateNiceScale(0, MAX, FINAL_CONFIG.value.style.chart.grid.scale.ticks);
258-
const maxTotal = scale.max;
303+
const ZERO_POSITION = yLabels.value[0] ? yLabels.value[0].zero : drawingArea.value.bottom;
259304
260305
return unmutableDataset.value
261306
.filter(ds => !segregated.value.includes(ds.id))
262307
.map(ds => {
263308
const slicedSeries = ds.series.slice(slicer.value.start, slicer.value.end);
309+
const signedSliced = ds.signedSeries.slice(slicer.value.start, slicer.value.end);
310+
311+
const x = slicedSeries.map((_dp, i) => {
312+
return drawingArea.value.left + (barSlot.value * i) + (barSlot.value * FINAL_CONFIG.value.style.chart.bars.gapRatio / 4);
313+
});
314+
315+
const y = slicedSeries.map((dp, i) => {
316+
const proportion = FINAL_CONFIG.value.style.chart.bars.distributed
317+
? (dp || 0) / datasetTotals.value[i]
318+
: (dp || 0) / maxTotal;
319+
320+
let currentY, height;
321+
if (dp > 0) {
322+
height = totalHeight * proportion;
323+
currentY = ZERO_POSITION - height - cumulativeY[i];
324+
cumulativeY[i] += height;
325+
} else {
326+
height = totalHeight * proportion;
327+
currentY = ZERO_POSITION + cumulativeNegY[i]
328+
cumulativeNegY[i] += Math.abs(height)
329+
}
330+
return currentY;
331+
});
332+
333+
const height = slicedSeries.map((dp, i) => {
334+
const proportion = FINAL_CONFIG.value.style.chart.bars.distributed
335+
? (dp || 0) / datasetTotals.value[i]
336+
: (dp || 0) / maxTotal;
337+
338+
if (dp > 0) {
339+
return totalHeight * proportion
340+
} else {
341+
return totalHeight * Math.abs(proportion)
342+
}
343+
});
344+
345+
const absoluteTotal = slicedSeries.map(v => Math.abs(v)).reduce((a, b) => a + b, 0);
264346
265347
return {
266348
...ds,
267349
proportions: slicedSeries.map((dp, i) => {
268350
if (FINAL_CONFIG.value.style.chart.bars.distributed) {
269351
return (dp || 0) / datasetTotals.value[i];
270352
} else {
271-
return (dp || 0) / maxTotal;
353+
return (dp || 0) / absoluteTotal;
272354
}
273355
}),
274356
series: slicedSeries,
275-
x: slicedSeries.map((_dp, i) => {
276-
return drawingArea.value.left + (barSlot.value * i) + (barSlot.value * FINAL_CONFIG.value.style.chart.bars.gapRatio / 4);
277-
}),
278-
y: slicedSeries.map((dp, i) => {
279-
const proportion = FINAL_CONFIG.value.style.chart.bars.distributed
280-
? (dp || 0) / datasetTotals.value[i]
281-
: (dp || 0) / maxTotal;
282-
283-
const height = totalHeight * proportion;
284-
const currentY = totalHeight - height - cumulativeY[i];
285-
cumulativeY[i] += height;
286-
287-
return currentY + drawingArea.value.top;
288-
}),
289-
height: slicedSeries.map((dp, i) => {
290-
const proportion = FINAL_CONFIG.value.style.chart.bars.distributed
291-
? (dp || 0) / datasetTotals.value[i]
292-
: (dp || 0) / maxTotal;
293-
294-
return totalHeight * proportion <= 0 ? 0 : totalHeight * proportion;
295-
}),
357+
signedSeries: signedSliced,
358+
x,
359+
y,
360+
height,
296361
};
297362
});
298363
});
@@ -311,13 +376,15 @@ const totalLabels = computed(() => {
311376
});
312377
313378
314-
function barDataLabel(val, datapoint, index, dpIndex) {
379+
function barDataLabel(val, datapoint, index, dpIndex, signed) {
380+
381+
const appliedValue = FINAL_CONFIG.value.style.chart.bars.distributed ? signed === 1 ? val : -val : val;
315382
return applyDataLabel(
316383
FINAL_CONFIG.value.style.chart.bars.dataLabels.formatter,
317-
val,
384+
appliedValue,
318385
dataLabel({
319386
p: FINAL_CONFIG.value.style.chart.bars.dataLabels.prefix,
320-
v: val,
387+
v: appliedValue,
321388
s: FINAL_CONFIG.value.style.chart.bars.dataLabels.suffix,
322389
r: FINAL_CONFIG.value.style.chart.bars.dataLabels.rounding,
323390
}),
@@ -368,7 +435,7 @@ function useTooltip(seriesIndex) {
368435
}
369436
});
370437
371-
const sum = datapoint.map(d => d.value).reduce((a, b) => a + b, 0);
438+
const sum = datapoint.map(d => Math.abs(d.value)).reduce((a, b) => a + b, 0);
372439
373440
if (isFunction(customFormat) && functionReturnsString(() => customFormat({
374441
seriesIndex,
@@ -412,7 +479,7 @@ function useTooltip(seriesIndex) {
412479
s: FINAL_CONFIG.value.style.chart.bars.dataLabels.suffix,
413480
r: roundingValue,
414481
}) : ''} ${parenthesis[0]}${showPercentage ? dataLabel({
415-
v: isNaN(ds.value / sum) ? 0 : ds.value / sum * 100,
482+
v: isNaN(ds.value / sum) ? 0 : Math.abs(ds.value) / sum * 100, // Negs are absed to show relative proportion to absolute total. It's opinionated.
416483
s: '%',
417484
r: roundingPercentage,
418485
}) : ''}${parenthesis[1]}
@@ -670,28 +737,21 @@ defineExpose({
670737
</template>
671738
672739
<!-- STACKED BARS -->
673-
<g v-for="(dp, i) in formattedDataset">
674-
<path
740+
<g v-for="(dp, i) in formattedDataset">
741+
<rect
675742
v-for="(rect, j) in dp.x"
676-
:d="(i === formattedDataset.length - 1 && formattedDataset.at(-1).series[j]) || (!formattedDataset.at(-1).series[j] && i < formattedDataset.length - 1 && formattedDataset[i].series[j] && !formattedDataset[i+1].series[j])
677-
? `M ${rect},${dp.y[j]}
678-
h ${barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2) - FINAL_CONFIG.style.chart.bars.borderRadius}
679-
a ${FINAL_CONFIG.style.chart.bars.borderRadius},${FINAL_CONFIG.style.chart.bars.borderRadius} 0 0 1 ${FINAL_CONFIG.style.chart.bars.borderRadius},${FINAL_CONFIG.style.chart.bars.borderRadius}
680-
v ${dp.height[j] - FINAL_CONFIG.style.chart.bars.borderRadius < 0 ? 0 : dp.height[j] - FINAL_CONFIG.style.chart.bars.borderRadius}
681-
h -${barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2)}
682-
v -${dp.height[j] - FINAL_CONFIG.style.chart.bars.borderRadius < 0 ? 0 : dp.height[j] - FINAL_CONFIG.style.chart.bars.borderRadius}
683-
a ${FINAL_CONFIG.style.chart.bars.borderRadius},${FINAL_CONFIG.style.chart.bars.borderRadius} 0 0 1 ${FINAL_CONFIG.style.chart.bars.borderRadius},-${FINAL_CONFIG.style.chart.bars.borderRadius} z`
684-
: `M ${rect},${dp.y[j]}
685-
h ${barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2)}
686-
v ${dp.height[j] < 0 ? 0 : dp.height[j]}
687-
h -${barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2)} z`"
743+
:x="rect"
744+
:y="dp.y[j] < 0 ? 0 : dp.y[j]"
745+
:height="dp.height[j] < 0 ? 0.0001 : dp.height[j]"
746+
:rx="FINAL_CONFIG.style.chart.bars.borderRadius > dp.height[j] / 2 ? (dp.height[j] < 0 ? 0 : dp.height[j]) / 2 : FINAL_CONFIG.style.chart.bars.borderRadius "
747+
:width="barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2)"
688748
:fill="FINAL_CONFIG.style.chart.bars.gradient.show ? `url(#gradient_${dp.id})` : dp.color"
689749
:stroke="FINAL_CONFIG.style.chart.backgroundColor"
690750
:stroke-width="FINAL_CONFIG.style.chart.bars.strokeWidth"
691751
stroke-linecap="round"
692752
stroke-linejoin="round"
693-
:class="{ 'vue-data-ui-bar-animated': FINAL_CONFIG.useCssAnimation }"
694-
/>
753+
:class="{ 'vue-data-ui-bar-animated': FINAL_CONFIG.useCssAnimation, 'vue-data-ui-bar-transition': isLoaded }"
754+
/>
695755
</g>
696756
697757
<!-- X AXIS -->
@@ -751,13 +811,15 @@ defineExpose({
751811
<text
752812
v-for="(rect, j) in dp.x"
753813
:x="rect + (barSlot * (1 - FINAL_CONFIG.style.chart.bars.gapRatio / 2) / 2)"
754-
:y="dp.y[j] + (dp.proportions[j] * drawingArea.height / 2) + FINAL_CONFIG.style.chart.bars.dataLabels.fontSize / 3"
814+
:y="dp.y[j] + dp.height[j] / 2 + FINAL_CONFIG.style.chart.bars.dataLabels.fontSize / 3"
755815
:font-size="FINAL_CONFIG.style.chart.bars.dataLabels.fontSize"
756816
:fill="FINAL_CONFIG.style.chart.bars.dataLabels.adaptColorToBackground ? adaptColorToBackground(dp.color) : FINAL_CONFIG.style.chart.bars.dataLabels.color"
757817
:font-weight="FINAL_CONFIG.style.chart.bars.dataLabels.bold ? 'bold' : 'normal'"
758818
text-anchor="middle"
759819
>
760-
{{ FINAL_CONFIG.style.chart.bars.showDistributedPercentage && FINAL_CONFIG.style.chart.bars.distributed ? barDataLabelPercentage(dp.proportions[j] * 100, dp, i, j) : barDataLabel(dp.series[j], dp, i, j) }}
820+
{{ FINAL_CONFIG.style.chart.bars.showDistributedPercentage && FINAL_CONFIG.style.chart.bars.distributed ?
821+
barDataLabelPercentage(dp.proportions[j] * 100, dp, i, j) :
822+
barDataLabel(dp.series[j], dp, i, j, dp.signedSeries[j]) }}
761823
</text>
762824
</g>
763825
@@ -766,7 +828,7 @@ defineExpose({
766828
<text
767829
v-for="(total, i) in totalLabels"
768830
:x="drawingArea.left + (barSlot * i) + barSlot / 2"
769-
:y="(FINAL_CONFIG.style.chart.bars.distributed ? drawingArea.top - FINAL_CONFIG.style.chart.bars.totalValues.fontSize / 3 : total.y) + FINAL_CONFIG.style.chart.bars.totalValues.offsetY"
831+
:y="drawingArea.top - FINAL_CONFIG.style.chart.bars.totalValues.fontSize / 3"
770832
text-anchor="middle"
771833
:font-size="FINAL_CONFIG.style.chart.bars.totalValues.fontSize"
772834
:font-weight="FINAL_CONFIG.style.chart.bars.totalValues.bold ? 'bold' : 'normal'"
@@ -827,7 +889,7 @@ defineExpose({
827889
:x="drawingArea.left + (i * barSlot)"
828890
:y="drawingArea.top"
829891
:width="barSlot"
830-
:height="drawingArea.height"
892+
:height="drawingArea.height < 0 ? 0 : drawingArea.height"
831893
@click="selectDatapoint(i)"
832894
@mouseenter="useTooltip(i)"
833895
@mouseleave="trapIndex = null; isTooltip = false"
@@ -976,6 +1038,10 @@ defineExpose({
9761038
transform-origin: center;
9771039
}
9781040
1041+
.vue-data-ui-bar-transition {
1042+
transition: all 0.2s ease-in-out !important;
1043+
}
1044+
9791045
@keyframes vueDataUiBarAnimation {
9801046
0% {
9811047
transform: scale(0.9,0.9);

0 commit comments

Comments
 (0)