init
This commit is contained in:
160
client/src/components/MetricChart.vue
Normal file
160
client/src/components/MetricChart.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<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 { fetchHistoricHourly } from '../services/weather'
|
||||
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 now = new Date()
|
||||
const start = new Date(now.getTime() - 24 * 3600000).toISOString()
|
||||
const end = new Date(now.getTime() + 48 * 3600000).toISOString()
|
||||
const [hist, fore] = await Promise.allSettled([
|
||||
fetchHistoricHourly(start, now.toISOString()),
|
||||
api.hourly(now.toISOString(), end),
|
||||
])
|
||||
if (hist.status === 'fulfilled') historic.value = hist.value.filter(r => r[props.metricKey] != null)
|
||||
if (fore.status === 'fulfilled') forecast.value = fore.value.filter(r => r[props.metricKey] != null)
|
||||
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>
|
||||
Reference in New Issue
Block a user