AnhviNguyen
Forecast, dashboard, pronunciation, nginx fixes (exclude large ML weights)
3092643
Raw
History Blame Contribute Delete
6.02 kB
<template>
<div class="w-full">
<svg :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" class="overflow-visible">
<!-- Grid -->
<g v-for="tick in yTicks" :key="tick">
<line
:x1="padL"
:y1="yScale(tick)"
:x2="width - padR"
:y2="yScale(tick)"
stroke="var(--border)"
stroke-width="1"
stroke-dasharray="3 3"
/>
<text
:x="padL - 8"
:y="yScale(tick) + 4"
text-anchor="end"
class="fill-[var(--ink3)] text-[10px]"
>{{ tick.toFixed(1) }}</text>
</g>
<!-- Target line -->
<line
v-if="target"
:x1="padL"
:y1="yScale(target)"
:x2="width - padR"
:y2="yScale(target)"
stroke="var(--spotify-green)"
stroke-width="1.5"
stroke-dasharray="6 4"
/>
<!-- Confidence band (forecast) -->
<path
v-if="bandPath"
:d="bandPath"
fill="rgba(52,211,153,0.12)"
stroke="none"
/>
<!-- Forecast line -->
<polyline
v-if="forecastLine"
:points="forecastLine"
fill="none"
stroke="var(--spotify-green)"
stroke-width="2"
stroke-dasharray="5 3"
/>
<!-- History line -->
<polyline
v-if="historyLine"
:points="historyLine"
fill="none"
stroke="#111"
stroke-width="2.5"
/>
<!-- Observed dots -->
<circle
v-for="(pt, i) in historyPoints"
:key="`h-${i}`"
:cx="xScale(pt.idx)"
:cy="yScale(pt.y ?? pt.yhat)"
r="3.5"
fill="#111"
/>
<!-- X labels -->
<text
v-for="lbl in xLabels"
:key="lbl.idx"
:x="xScale(lbl.idx)"
:y="height - 6"
text-anchor="middle"
class="fill-[var(--ink3)] text-[9px]"
>{{ lbl.label }}</text>
</svg>
<div class="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-[var(--ink3)]">
<span class="flex items-center gap-1.5"><span class="inline-block h-0.5 w-5 bg-[#111]"></span> Thực tế</span>
<span class="flex items-center gap-1.5"><span class="inline-block h-0.5 w-5 border-t-2 border-dashed border-emerald-400"></span> Dự báo</span>
<span class="flex items-center gap-1.5"><span class="inline-block h-3 w-5 rounded bg-emerald-100"></span> Khoảng tin cậy 90%</span>
<span v-if="target" class="flex items-center gap-1.5"><span class="inline-block h-0.5 w-5 border-t border-dashed border-emerald-500"></span> Mục tiêu {{ target }}</span>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
history: { type: Array, default: () => [] },
forecast: { type: Array, default: () => [] },
target: { type: Number, default: null },
width: { type: Number, default: 560 },
height: { type: Number, default: 220 },
})
const padL = 36
const padR = 12
const padT = 12
const padB = 28
const allPoints = computed(() => {
const hist = (props.history || []).map((p, idx) => ({
...p,
idx,
kind: 'history',
}))
const base = hist.length
const fc = (props.forecast || []).map((p, i) => ({
...p,
idx: base + i,
kind: 'forecast',
}))
return [...hist, ...fc]
})
const yMin = computed(() => {
const vals = allPoints.value.flatMap(p => [p.y, p.yhat, p.yhat_lower, p.yhat_upper].filter(v => v != null))
const lo = vals.length ? Math.min(...vals) : 4
return Math.max(0, Math.floor((lo - 0.3) * 2) / 2)
})
const yMax = computed(() => {
const vals = allPoints.value.flatMap(p => [p.y, p.yhat, p.yhat_lower, p.yhat_upper].filter(v => v != null))
const hi = vals.length ? Math.max(...vals) : 8
return Math.min(9, Math.ceil((hi + 0.3) * 2) / 2)
})
const yTicks = computed(() => {
const ticks = []
for (let v = yMin.value; v <= yMax.value + 0.01; v += 0.5) ticks.push(v)
return ticks
})
const plotW = computed(() => props.width - padL - padR)
const plotH = computed(() => props.height - padT - padB)
function yScale(v) {
const range = yMax.value - yMin.value || 1
return padT + plotH.value - ((v - yMin.value) / range) * plotH.value
}
function xScale(idx) {
const n = Math.max(allPoints.value.length - 1, 1)
return padL + (idx / n) * plotW.value
}
const historyPoints = computed(() =>
allPoints.value.filter(p => p.kind === 'history')
)
const historyLine = computed(() =>
historyPoints.value
.map(p => `${xScale(p.idx)},${yScale(p.y ?? p.yhat)}`)
.join(' ')
)
const forecastLine = computed(() => {
const lastHist = historyPoints.value.at(-1)
const fc = allPoints.value.filter(p => p.kind === 'forecast')
if (!fc.length) return ''
const pts = []
if (lastHist) pts.push(`${xScale(lastHist.idx)},${yScale(lastHist.y ?? lastHist.yhat)}`)
fc.forEach(p => pts.push(`${xScale(p.idx)},${yScale(p.yhat)}`))
return pts.join(' ')
})
const bandPath = computed(() => {
const fc = allPoints.value.filter(p => p.kind === 'forecast')
const lastHist = historyPoints.value.at(-1)
if (!fc.length) return ''
const upper = []
const lower = []
if (lastHist) {
upper.push([xScale(lastHist.idx), yScale(lastHist.y ?? lastHist.yhat)])
lower.push([xScale(lastHist.idx), yScale(lastHist.y ?? lastHist.yhat)])
}
fc.forEach(p => {
upper.push([xScale(p.idx), yScale(p.yhat_upper)])
lower.unshift([xScale(p.idx), yScale(p.yhat_lower)])
})
const path = [...upper, ...lower]
if (!path.length) return ''
return `M ${path.map(([x, y]) => `${x},${y}`).join(' L ')} Z`
})
const xLabels = computed(() => {
const pts = allPoints.value
if (!pts.length) return []
const picks = new Set([0, Math.floor(pts.length / 2), pts.length - 1])
return [...picks].map(idx => ({
idx,
label: formatDate(pts[idx]?.ds),
}))
})
function formatDate(ds) {
if (!ds) return ''
const d = new Date(ds)
return d.toLocaleDateString('vi-VN', { day: '2-digit', month: '2-digit' })
}
</script>