updated graph

This commit is contained in:
2026-06-26 01:41:27 -04:00
parent efd08cbabe
commit 74a8018f01

View File

@@ -7,20 +7,51 @@ const emit = defineEmits<{(e: 'close'): void}>();
const mode = ref<'hourly' | 'daily'>('hourly'); const mode = ref<'hourly' | 'daily'>('hourly');
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
const loading = ref(false);
const selectedPoint = ref<{t: number, v: number} | null>(null);
const W = 600, H = 200, PAD = 30; const W = 600, H = 200, PAD = 30;
watch([() => props.metricKey, mode], async ([key]) => { const customStart = ref('');
if(!key) return; 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(); const past = new Date(), future = new Date();
if(mode.value === 'hourly') { if(mode.value === 'hourly') {
past.setHours(past.getHours() - 24, 0, 0, 0); past.setHours(past.getHours() - 24, 0, 0, 0);
future.setHours(future.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 { } else {
past.setDate(past.getDate() - 3); past.setDate(past.getDate() - 3);
future.setDate(future.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}); }, {immediate: true});
const now = new Date(); const now = new Date();
@@ -30,67 +61,78 @@ const points = computed(() => {
return rows.value.map(r => ({ return rows.value.map(r => ({
t: new Date(r.time as string).getTime(), t: new Date(r.time as string).getTime(),
v: r[props.metricKey!] as number | null, 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 chart = computed(() => {
const pts = points.value; 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 minT = Math.min(...ts), maxT = Math.max(...ts);
const rawMin = Math.min(...vs), rawMax = Math.max(...vs); const rawMin = Math.min(...vs), rawMax = Math.max(...vs);
const pad = (rawMax - rawMin) * 0.1 || 1; const vpad = (rawMax - rawMin) * 0.1 || 1;
const minV = rawMin - pad, maxV = rawMax + pad; const minV = rawMin - vpad, maxV = rawMax + vpad;
const xOf = (t: number) => PAD + ((t - minT) / (maxT - minT || 1)) * (W - PAD * 2); 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 yOf = (v: number) => H - PAD - ((v - minV) / (maxV - minV || 1)) * (H - PAD * 2);
const nowT = now.getTime(); const nowT = now.getTime();
const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT))); 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); 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); const fut = pts.filter(p => p.t >= nowT);
if(fut[0]?.t !== nearest.t) fut.unshift({t: nearest.t, v: nearest.v}); // stitch the join so lines meet cleanly at the now-line
const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v}; 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) => 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 yLabels = Array.from({length: 5}, (_, i) => {
const frac = i / 4; const v = minV + (i / 4) * (maxV - minV);
const v = minV + frac * (maxV - minV);
return {y: yOf(v), label: v.toFixed(1)}; return {y: yOf(v), label: v.toFixed(1)};
}); });
// x-axis labels
const xLabels: {x: number, label: string}[] = []; const xLabels: {x: number, label: string}[] = [];
const span = maxT - minT;
const tickCount = mode.value === 'hourly' ? 9 : 7; const tickCount = mode.value === 'hourly' ? 9 : 7;
for(let i = 0; i <= tickCount; i++) { 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 d = new Date(t);
const label = mode.value === 'hourly' xLabels.push({x: xOf(t), label: mode.value === 'hourly'
? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) ? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})
: d.toLocaleDateString([], {month: 'short', day: 'numeric'}); : d.toLocaleDateString([], {month: 'short', day: 'numeric'})});
xLabels.push({x: xOf(t), label});
} }
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};
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
.backdrop { .backdrop {
position: fixed; position: fixed; inset: 0;
inset: 0;
background: rgba(0,0,0,0.6); background: rgba(0,0,0,0.6);
z-index: 1000; z-index: 1000;
display: flex; display: flex; align-items: center; justify-content: center;
align-items: center;
justify-content: center;
padding: 16px; padding: 16px;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
} }
@@ -99,53 +141,54 @@ const chart = computed(() => {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 24px;
width: 100%; width: 100%; max-width: 700px;
max-width: 700px;
} }
.modal-header { .modal-header {
display: flex; display: flex; align-items: center; justify-content: space-between;
align-items: center;
justify-content: space-between;
margin-bottom: 16px; margin-bottom: 16px;
.title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; } .title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; }
.close-btn { .close-btn {
background: none; background: none; border: 1px solid var(--border); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; width: 32px; height: 32px; cursor: pointer; font-size: 16px;
color: var(--text);
border-radius: 8px;
width: 32px;
height: 32px;
cursor: pointer;
font-size: 16px;
&:hover { background: var(--hover); } &:hover { background: var(--hover); }
} }
} }
.mode-toggle { .controls {
display: flex; display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
gap: 6px;
margin-bottom: 16px; margin-bottom: 16px;
button { button {
font-size: 11px; font-size: 11px; padding: 4px 12px; border-radius: 6px;
padding: 4px 12px; border: 1px solid var(--border); background: none; color: var(--text-muted); cursor: pointer;
border-radius: 6px;
border: 1px solid var(--border);
background: none;
color: var(--text-muted);
cursor: pointer;
&.active { background: var(--accent); color: #fff; border-color: var(--accent); } &.active { background: var(--accent); color: #fff; border-color: var(--accent); }
} }
input {
font-size: 11px; padding: 3px 6px; border-radius: 6px;
border: 1px solid var(--border); background: var(--bg); color: var(--text);
}
.apply-btn {
font-size: 11px; padding: 4px 10px; border-radius: 6px;
border: 1px solid var(--accent); background: var(--accent); color: #fff; cursor: pointer;
}
} }
.current-val { .current-val {
font-size: 13px; font-size: 13px; color: var(--text-muted); margin-bottom: 12px;
color: var(--text-muted);
margin-bottom: 12px;
span { color: var(--accent); font-weight: 700; } span { color: var(--accent); font-weight: 700; }
.val-time { font-size: 11px; margin-left: 6px; opacity: 0.6; }
} }
svg { width: 100%; overflow: visible; } .chart-wrap { position: relative; }
.tick-label { .loading-overlay {
font-size: 9px; position: absolute; inset: 0;
fill: var(--text-muted); display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.2); border-radius: 8px;
.spinner {
width: 28px; height: 28px;
border: 3px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite;
}
} }
@keyframes spin { to { transform: rotate(360deg); } }
svg { width: 100%; overflow: visible; cursor: crosshair; }
.tick-label { font-size: 9px; fill: var(--text-muted); }
</style> </style>
<template> <template>
@@ -158,41 +201,44 @@ svg { width: 100%; overflow: visible; }
<button class="close-btn" @click="emit('close')"></button> <button class="close-btn" @click="emit('close')"></button>
</div> </div>
<div class="mode-toggle"> <div class="controls">
<button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly ±24h</button> <button :class="{active: mode === 'hourly'}" @click="mode = 'hourly'">Hourly</button>
<button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily ±3d</button> <button :class="{active: mode === 'daily'}" @click="mode = 'daily'">Daily</button>
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customStart"/>
<span style="font-size:11px;color:var(--text-muted)">to</span>
<input :type="mode === 'hourly' ? 'datetime-local' : 'date'" v-model="customEnd"/>
<button class="apply-btn" @click="fetchData">Apply</button>
</div> </div>
<div v-if="chart.dot" class="current-val">Now: <span>{{ (chart.dot.v as number).toFixed(2) }}</span></div> <div v-if="chart.dot" class="current-val">
<span>{{ (chart.dot.v as number).toFixed(2) }}</span>
<span class="val-time">{{ new Date(chart.dot.t).toLocaleString() }}</span>
</div>
<svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none"> <div class="chart-wrap">
<!-- y-axis grid lines & labels --> <svg :viewBox="`0 0 ${W} ${H}`" preserveAspectRatio="none" @click="onSvgClick">
<template v-for="tick in chart.yLabels" :key="tick.y"> <template v-for="tick in chart.yLabels" :key="tick.y">
<line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/> <line :x1="PAD" :y1="tick.y" :x2="W-PAD" :y2="tick.y" stroke="var(--border)" stroke-width="0.5" opacity="0.5"/>
<text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text> <text :x="PAD - 4" :y="tick.y + 3" text-anchor="end" class="tick-label">{{ tick.label }}</text>
</template> </template>
<template v-for="tick in chart.xLabels" :key="tick.x">
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
</template>
<!-- x-axis labels --> <line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
<template v-for="tick in chart.xLabels" :key="tick.x"> <line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
<text :x="tick.x" :y="H - 4" text-anchor="middle" class="tick-label">{{ tick.label }}</text>
</template>
<!-- axes --> <line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD"
<line :x1="PAD" :y1="PAD" :x2="PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/> stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
<line :x1="PAD" :y1="H-PAD" :x2="W-PAD" :y2="H-PAD" stroke="var(--border)" stroke-width="1"/>
<!-- now line --> <path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/>
<line :x1="chart.currentX" :y1="PAD" :x2="chart.currentX" :y2="H-PAD" <path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
stroke="var(--accent)" stroke-width="1" stroke-dasharray="3 2" opacity="0.6"/>
<!-- historic solid --> <circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="5" fill="var(--accent)"/>
<path :d="chart.path.hist" fill="none" stroke="var(--accent)" stroke-width="2"/> </svg>
<!-- future dashed -->
<path :d="chart.path.fut" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="5 3" opacity="0.5"/>
<!-- current dot --> <div v-if="loading" class="loading-overlay"><div class="spinner"/></div>
<circle v-if="chart.dot" :cx="chart.dot.x" :cy="chart.dot.y" r="4" fill="var(--accent)"/> </div>
</svg>
</div> </div>
</div> </div>
</Transition> </Transition>