Files
weather-station/client/src/components/MetricChart.vue
2026-06-25 10:21:55 -04:00

156 lines
3.9 KiB
Vue

<script setup lang="ts">
import {computed, ref, onMounted} from 'vue';
import {Line} from 'vue-chartjs';
import {
Chart as ChartJS, CategoryScale, LinearScale, PointElement,
LineElement, Tooltip, Legend, Filler, type ChartOptions
} from 'chart.js';
import {METRICS, formatValue} from '../services/units';
import type {DataRow} from '../services/api';
import {api} from '../services/api';
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend, Filler);
const props = defineProps<{metricKey: string; currentData: DataRow}>();
const historic = ref<DataRow[]>([]);
const forecast = ref<DataRow[]>([]);
const loading = ref(true);
const meta = computed(() => METRICS[props.metricKey]);
onMounted(async () => {
const start = new Date(Date.now() - 24 * 3600000);
const end = new Date(Date.now() + 48 * 3600000);
const hourly = await api.hourly(start, end);
historic.value = hourly.filter((h: any) => new Date(h.time).getTime() <= Date.now());
forecast.value = hourly.filter((h: any) => new Date(h.time).getTime() > Date.now());
loading.value = false;
});
const nowLabel = computed(() => {
const d = new Date();
return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
});
const chartData = computed(() => {
const color = meta.value?.color ?? '#38bdf8';
const histLabels = historic.value.map(r => r.time as string);
const foreLabels = forecast.value.map(r => r.time as string);
const histVals = historic.value.map(r => r[props.metricKey] as number);
const foreVals = forecast.value.map(r => r[props.metricKey] as number);
const currentVal = props.currentData[props.metricKey] as number | null;
const allLabels = [...histLabels, nowLabel.value, ...foreLabels];
const histData = [...histVals, currentVal, ...foreVals.map(() => null)];
const foreData = [...histVals.map(() => null), currentVal, ...foreVals];
return {
labels: allLabels,
datasets: [
{
label: 'Historic',
data: histData,
borderColor: color,
backgroundColor: `${color}22`,
borderWidth: 2,
pointRadius: 0,
tension: 0.3,
fill: true,
spanGaps: false,
},
{
label: 'Forecast',
data: foreData,
borderColor: color,
backgroundColor: 'transparent',
borderWidth: 2,
borderDash: [6, 4],
pointRadius: 0,
tension: 0.3,
fill: false,
spanGaps: false,
},
{
label: 'Now',
data: allLabels.map(l => l === nowLabel.value ? currentVal : null),
borderColor: '#ffffff88',
backgroundColor: color,
pointRadius: 6,
pointHoverRadius: 8,
borderWidth: 0,
showLine: false,
spanGaps: false,
}
]
};
});
const chartOptions = computed<ChartOptions<'line'>>(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {mode: 'index', intersect: false},
plugins: {
legend: {display: false},
tooltip: {
callbacks: {
label: ctx => formatValue(props.metricKey, ctx.parsed.y),
}
}
},
scales: {
x: {
ticks: {
color: 'var(--text-muted)',
maxTicksLimit: 12,
maxRotation: 0,
callback(val, i) {
const lbl = this.getLabelForValue(i);
return lbl?.length === 5 && lbl.endsWith(':00') ? lbl : '';
}
},
grid: {color: 'var(--border)'},
},
y: {
ticks: {color: 'var(--text-muted)', callback: v => formatValue(props.metricKey, v as number)},
grid: {color: 'var(--border)'},
}
},
annotation: {
annotations: {
nowLine: {
type: 'line',
xMin: nowLabel.value,
xMax: nowLabel.value,
borderColor: '#ffffff44',
borderWidth: 1,
borderDash: [4, 4],
}
}
}
}));
</script>
<style scoped lang="scss">
.chart-wrap {
position: relative;
height: 260px;
width: 100%;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 260px;
color: var(--text-muted);
font-size: 14px;
}
</style>
<template>
<div v-if="loading" class="loading">Loading chart</div>
<div v-else class="chart-wrap">
<Line :data="chartData" :options="chartOptions"/>
</div>
</template>