diff --git a/client/src/components/AirQuality.vue b/client/src/components/AirQuality.vue new file mode 100644 index 0000000..505b75e --- /dev/null +++ b/client/src/components/AirQuality.vue @@ -0,0 +1,18 @@ + + + diff --git a/client/src/components/CurrentWeather.vue b/client/src/components/CurrentWeather.vue index efd6be0..522169b 100644 --- a/client/src/components/CurrentWeather.vue +++ b/client/src/components/CurrentWeather.vue @@ -1,20 +1,10 @@ - - diff --git a/client/src/components/GraphModal.vue b/client/src/components/GraphModal.vue index 80208fc..8e4798a 100644 --- a/client/src/components/GraphModal.vue +++ b/client/src/components/GraphModal.vue @@ -11,12 +11,16 @@ const W = 600, H = 200, PAD = 30; watch([() => props.metricKey, mode], async ([key]) => { if(!key) return; - const now = new Date(), past = new Date(), future = new Date(); - past.setDate(past.getDate() - 3); - future.setDate(future.getDate() + 3); - rows.value = mode.value === 'hourly' - ? await api.hourly(past, future) - : await api.daily(past, future); + const past = new Date(), future = new Date(); + if(mode.value === 'hourly') { + past.setHours(past.getHours() - 24); + future.setHours(future.getHours() + 24); + rows.value = await api.hourly(past, future); + } else { + past.setDate(past.getDate() - 3); + future.setDate(future.getDate() + 3); + rows.value = await api.daily(past, future); + } }, {immediate: true}); const now = new Date(); @@ -29,34 +33,52 @@ const points = computed(() => { })).filter(p => p.v != null); }); -const {minT, maxT, minV, maxV, path, currentX} = computed(() => { +const chart = computed(() => { const pts = points.value; - if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:'', currentX: 0}; + if(!pts.length) return {minT:0, maxT:1, minV:0, maxV:1, path:{hist:'',fut:''}, currentX:0, dot:null as any, yLabels:[] as any[]}; + const ts = pts.map(p => p.t), vs = pts.map(p => p.v as number); const minT = Math.min(...ts), maxT = Math.max(...ts); - const minV = Math.min(...vs), maxV = Math.max(...vs); + const rawMin = Math.min(...vs), rawMax = Math.max(...vs); + const pad = (rawMax - rawMin) * 0.1 || 1; + const minV = rawMin - pad, maxV = rawMax + pad; + 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 = xOf(nowT); + const currentX = Math.max(PAD, Math.min(W - PAD, xOf(nowT))); - // split historic vs future const hist = pts.filter(p => p.t <= nowT); const fut = pts.filter(p => p.t >= nowT); - const histPath = hist.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' '); - const futPath = fut.map((p, i) => `${i===0?'M':'L'} ${xOf(p.t).toFixed(1)} ${yOf(p.v as number).toFixed(1)}`).join(' '); - const path = {hist: histPath, fut: futPath}; - return {minT, maxT, minV, maxV, path, currentX}; -}).value; -const currentDot = computed(() => { - const pts = points.value; - if(!pts.length || !props.metricKey) return null; - const nowT = now.getTime(); - const closest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b); - 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); - return {x: xOf(closest.t), y: yOf(closest.v as number), v: closest.v}; + // stitch current point into both paths so lines meet + const nearest = pts.reduce((a, b) => Math.abs(a.t - nowT) < Math.abs(b.t - nowT) ? a : b); + const dot = {x: xOf(nearest.t), y: yOf(nearest.v as number), v: nearest.v}; + + 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(' '); + + // y-axis labels (5 ticks) + const yLabels = Array.from({length: 5}, (_, i) => { + const frac = i / 4; + const v = minV + frac * (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 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}); + } + + return {minV, maxV, path: {hist: toPath(hist), fut: toPath(fut)}, currentX, dot, yLabels, xLabels}; }); @@ -85,8 +107,7 @@ const currentDot = computed(() => { align-items: center; justify-content: space-between; margin-bottom: 16px; - - .title { font-size: 16px; font-weight: 700; color: var(--text); } + .title { font-size: 16px; font-weight: 700; color: var(--text); text-transform: capitalize; } .close-btn { background: none; border: 1px solid var(--border); @@ -103,7 +124,6 @@ const currentDot = computed(() => { display: flex; gap: 6px; margin-bottom: 16px; - button { font-size: 11px; padding: 4px 12px; @@ -115,7 +135,17 @@ const currentDot = computed(() => { &.active { background: var(--accent); color: #fff; border-color: var(--accent); } } } +.current-val { + font-size: 13px; + color: var(--text-muted); + margin-bottom: 12px; + span { color: var(--accent); font-weight: 700; } +} svg { width: 100%; overflow: visible; } +.tick-label { + font-size: 9px; + fill: var(--text-muted); +}