Files
dozzle/assets/components/BarChart.vue
T
2026-05-24 09:59:27 -07:00

138 lines
3.6 KiB
Vue

<template>
<div ref="chartContainer" class="flex items-end gap-[2px]" @mousemove="onContainerHover">
<div
v-for="(bar, i) in downsampledBars"
:key="i"
class="bar min-h-px flex-1 rounded-t-sm"
:class="barClass"
:style="{ '--height': `${maxValue > 0 ? (bar.percent / maxValue) * 100 : 0}%` }"
></div>
</div>
</template>
<style scoped>
.bar {
height: var(--height);
will-change: height;
contain: layout;
}
</style>
<script setup lang="ts">
export interface BarDataPoint {
percent: number;
value: number;
}
const { chartData, barClass = "" } = defineProps<{
chartData: BarDataPoint[];
barClass?: string;
}>();
const hoverValue = defineEmit<[value: number]>();
const chartContainer = ref<HTMLElement | null>(null);
const { width } = useElementSize(chartContainer);
const BAR_WIDTH = 3;
const GAP = 2;
const availableBars = computed(() => Math.floor(width.value / (BAR_WIDTH + GAP)));
const bucketSize = computed(() => Math.ceil(chartData.length / availableBars.value));
const downsampledBars = ref<BarDataPoint[]>([]);
const maxValue = computed(() => {
const dataMax = Math.max(0, ...downsampledBars.value.map((b) => b.percent));
return Math.max(dataMax * 1.25, 1);
});
// Full recalculate when width/bucket size changes
watch([availableBars, bucketSize], () => {
recalculate();
changeCounter.value = 0;
});
// On data changes, only update the last bar unless a new bucket boundary is crossed.
// A wholesale replacement of the series (e.g. switching containers) is not detected
// here; the parent owns that and must call the exposed recalculate() on switch.
const changeCounter = ref(0);
let initialized = false;
watch(
() => chartData.at(-1),
() => {
if (!initialized) {
initialized = true;
recalculate();
return;
}
changeCounter.value++;
if (changeCounter.value >= bucketSize.value) {
recalculate();
changeCounter.value = 0;
} else {
updateLastBar();
}
},
);
defineExpose({ recalculate });
function averageBucket(bucket: BarDataPoint[]): BarDataPoint {
const percent = bucket.reduce((sum, d) => sum + d.percent, 0) / bucket.length;
const value = bucket.reduce((sum, d) => sum + d.value, 0) / bucket.length;
return { percent, value };
}
function recalculate() {
if (availableBars.value === 0) return;
if (chartData.length <= availableBars.value) {
downsampledBars.value = [...chartData];
return;
}
const size = bucketSize.value;
const result: BarDataPoint[] = [];
const numBuckets = Math.ceil(chartData.length / size);
for (let i = 0; i < numBuckets; i++) {
const start = i * size;
const end = Math.min(start + size, chartData.length);
result.push(averageBucket(chartData.slice(start, end)));
}
downsampledBars.value = result.slice(-availableBars.value);
}
function updateLastBar() {
if (downsampledBars.value.length === 0) return;
const size = bucketSize.value;
const lastBucketStart = (Math.ceil(chartData.length / size) - 1) * size;
const bucket = chartData.slice(lastBucketStart);
downsampledBars.value[downsampledBars.value.length - 1] = averageBucket(bucket);
}
function onContainerHover(event: MouseEvent) {
if (!chartContainer.value) return;
const bars = chartContainer.value.children;
if (bars.length === 0) return;
const mouseX = event.clientX;
let index = 0;
// Find the bar whose column contains the mouse x position
for (let i = 0; i < bars.length; i++) {
const rect = bars[i].getBoundingClientRect();
if (mouseX >= rect.left) {
index = i;
} else {
break;
}
}
hoverValue(downsampledBars.value[index].value);
}
</script>