diff --git a/client/src/components/GraphModal.vue b/client/src/components/GraphModal.vue index 8353462..f6dc8b7 100644 --- a/client/src/components/GraphModal.vue +++ b/client/src/components/GraphModal.vue @@ -7,20 +7,51 @@ const emit = defineEmits<{(e: 'close'): void}>(); const mode = ref<'hourly' | 'daily'>('hourly'); const rows = ref([]); +const loading = ref(false); +const selectedPoint = ref<{t: number, v: number} | null>(null); const W = 600, H = 200, PAD = 30; -watch([() => props.metricKey, mode], async ([key]) => { - if(!key) return; +const customStart = ref(''); +const customEnd = ref(''); + +function pad(n: number) { return String(n).padStart(2, '0'); } +function toDateTimeLocal(d: Date) { + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +function toDateLocal(d: Date) { + return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; +} + +function setDefaults() { const past = new Date(), future = new Date(); if(mode.value === 'hourly') { past.setHours(past.getHours() - 24, 0, 0, 0); future.setHours(future.getHours() + 24, 0, 0, 0); - rows.value = await api.hourly(past, future, `time,${props.metricKey}`); + customStart.value = toDateTimeLocal(past); + customEnd.value = toDateTimeLocal(future); } else { past.setDate(past.getDate() - 3); future.setDate(future.getDate() + 3); - rows.value = await api.daily(past, future, `time,${props.metricKey}`); + customStart.value = toDateLocal(past); + customEnd.value = toDateLocal(future); } +} + +async function fetchData() { + if(!props.metricKey) return; + selectedPoint.value = null; + loading.value = true; + const past = new Date(customStart.value); + const future = new Date(customEnd.value); + rows.value = mode.value === 'hourly' + ? await api.hourly(past, future, `time,${props.metricKey}`) + : await api.daily(past, future, `time,${props.metricKey}`); + loading.value = false; +} + +watch([() => props.metricKey, mode], () => { + setDefaults(); + fetchData(); }, {immediate: true}); const now = new Date(); @@ -30,67 +61,78 @@ const points = computed(() => { return rows.value.map(r => ({ t: new Date(r.time as string).getTime(), v: r[props.metricKey!] as number | null, - })).filter(p => p.v != null); + })).filter(p => p.v != null) as {t: number, v: number}[]; }); const chart = computed(() => { const pts = points.value; - if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:{hist:'',fut:''}, currentX:0, dot:null as any, yLabels:[] as any[]}; + if(!pts.length) return {path: {hist:'', fut:''}, currentX: 0, dot: null as any, yLabels: [] as any[], xLabels: [] as any[], dotPoints: [] as any[]}; - const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number); + const ts = pts.map(p => p.t), vs = pts.map(p => p.v); const minT = Math.min(...ts), maxT = Math.max(...ts); const rawMin = Math.min(...vs), rawMax = Math.max(...vs); - const pad = (rawMax - rawMin) * 0.1 || 1; - const minV = rawMin - pad, maxV = rawMax + pad; + const vpad = (rawMax - rawMin) * 0.1 || 1; + const minV = rawMin - vpad, maxV = rawMax + vpad; const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2); const yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2); const nowT = now.getTime(); const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT))); - const nearest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b); + // hist/fut always split at nowT — never affected by selected point const hist = pts.filter(p => p.t <= nowT); - if(hist.at(-1)?.t !== nearest.t) hist.push({t: nearest.t, v: nearest.v}); const fut = pts.filter(p => p.t >= nowT); - if(fut[0]?.t !== nearest.t) fut.unshift({t: nearest.t, v: nearest.v}); - const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v}; + // stitch the join so lines meet cleanly at the now-line + const joinPt = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b); + if(!hist.length || hist.at(-1)!.t !== joinPt.t) hist.push(joinPt); + if(!fut.length || fut[0]!.t !== joinPt.t) fut.unshift(joinPt); const toPath = (arr: typeof pts) => - arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' '); + arr.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v).toFixed(1)}`).join(' '); + + // selected dot — default to nearest point to now + const activePt = selectedPoint.value + ? pts.reduce((a, b) => Math.abs(a.t - selectedPoint.value!.t) < Math.abs(b.t - selectedPoint.value!.t) ? a : b) + : pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b); + const dot = {x: xOf(activePt.t), y: yOf(activePt.v), v: activePt.v, t: activePt.t}; - // y-axis labels (5 ticks) const yLabels = Array.from({length: 5}, (_, i) => { - const frac = i / 4; - const v = minV + frac * (maxV - minV); + const v = minV + (i / 4) * (maxV - minV); return {y: yOf(v), label: v.toFixed(1)}; }); - // x-axis labels const xLabels: {x: number, label: string}[] = []; - const span = maxT - minT; const tickCount = mode.value === 'hourly' ? 9 : 7; for(let i = 0; i <= tickCount; i++) { - const t = minT + (i / tickCount) * span; + const t = minT + (i / tickCount) * (maxT - minT); const d = new Date(t); - const label = mode.value === 'hourly' - ? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) - : d.toLocaleDateString([], {month: 'short', day: 'numeric'}); - xLabels.push({x: xOf(t), label}); + xLabels.push({x: xOf(t), label: mode.value === 'hourly' + ? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) + : d.toLocaleDateString([], {month: 'short', day: 'numeric'})}); } - return {minV, maxV, path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels}; + const dotPoints = pts.map(p => ({x: xOf(p.t), y: yOf(p.v), t: p.t, v: p.v})); + + return {path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels, dotPoints}; }); + +function onSvgClick(e: MouseEvent) { + const svg = e.currentTarget as SVGSVGElement; + const rect = svg.getBoundingClientRect(); + const svgX = ((e.clientX - rect.left) / rect.width) * W; + const pts = chart.value.dotPoints; + if(!pts.length) return; + const nearest = pts.reduce((a, b) => Math.abs(a.x - svgX) < Math.abs(b.x - svgX) ? a : b); + selectedPoint.value = {t: nearest.t, v: nearest.v}; +}