init
This commit is contained in:
99
client/src/components/CurrentWeather.vue
Normal file
99
client/src/components/CurrentWeather.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ data: DataRow }>()
|
||||
|
||||
const temp = computed(() => props.data.env_temp_c != null ? `${(props.data.env_temp_c as number).toFixed(1)}°C` : '—')
|
||||
const feels = computed(() => props.data.env_heat_index_c != null ? `Feels ${(props.data.env_heat_index_c as number).toFixed(1)}°C` : '')
|
||||
const icon = computed(() => props.data.forecast_weather_icon as string | null)
|
||||
const label = computed(() => props.data.forecast_weather_label as string | null)
|
||||
const humidity = computed(() => props.data.env_humidity != null ? `${(props.data.env_humidity as number).toFixed(0)}%` : '—')
|
||||
const wind = computed(() => props.data.wind_speed_kmh != null ? `${(props.data.wind_speed_kmh as number).toFixed(1)} km/h` : '—')
|
||||
const uvi = computed(() => props.data.light_uvi != null ? `UV ${(props.data.light_uvi as number).toFixed(1)}` : null)
|
||||
const sunrise = computed(() => props.data.sun_sunrise ? new Date(props.data.sun_sunrise as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
||||
const sunset = computed(() => props.data.sun_sunset ? new Date(props.data.sun_sunset as string).toLocaleTimeString('en', { hour: '2-digit', minute: '2-digit' }) : '—')
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.weather-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.temps {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.temp-main {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.temp-feel {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.weather-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; }
|
||||
.stat-value { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="hero">
|
||||
<div class="top">
|
||||
<img v-if="icon" :src="icon" class="weather-icon" alt="weather" />
|
||||
<div class="temps">
|
||||
<div class="temp-main">{{ temp }}</div>
|
||||
<div class="temp-feel">{{ feels }}</div>
|
||||
<div class="weather-label">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="stat-label">Humidity</span><span class="stat-value">{{ humidity }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Wind</span><span class="stat-value">{{ wind }}</span></div>
|
||||
<div v-if="uvi" class="stat"><span class="stat-label">UV</span><span class="stat-value">{{ uvi }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Sunrise</span><span class="stat-value">{{ sunrise }}</span></div>
|
||||
<div class="stat"><span class="stat-label">Sunset</span><span class="stat-value">{{ sunset }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
client/src/components/ForecastStrip.vue
Normal file
62
client/src/components/ForecastStrip.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ days: DataRow[] }>()
|
||||
|
||||
const items = computed(() => props.days.slice(0, 5).map(d => ({
|
||||
date: new Date(d.time as string).toLocaleDateString('en', { weekday: 'short', month: 'short', day: 'numeric' }),
|
||||
icon: d.forecast_weather_icon as string | null,
|
||||
label: d.forecast_weather_label as string,
|
||||
high: d.env_temp_max_c != null ? `${(d.env_temp_max_c as number).toFixed(1)}°` : '—',
|
||||
low: d.env_temp_min_c != null ? `${(d.env_temp_min_c as number).toFixed(1)}°` : '—',
|
||||
rain: d.forecast_precipitation_probability != null ? `${d.forecast_precipitation_probability}%` : null,
|
||||
})))
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.strip {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
|
||||
&::-webkit-scrollbar { height: 4px; }
|
||||
&::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
}
|
||||
|
||||
.day {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 10px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.day-name { font-size: 11px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; }
|
||||
.day-icon { width: 36px; height: 36px; object-fit: contain; }
|
||||
.day-label { font-size: 10px; color: var(--text-muted); text-align: center; line-height: 1.2; }
|
||||
.day-temps { display: flex; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.day-low { color: var(--text-muted); font-weight: 400; }
|
||||
.day-rain { font-size: 11px; color: #38bdf8; }
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="strip">
|
||||
<div v-for="item in items" :key="item.date" class="day">
|
||||
<div class="day-name">{{ item.date }}</div>
|
||||
<img v-if="item.icon" :src="item.icon" class="day-icon" :alt="item.label" />
|
||||
<div class="day-label">{{ item.label }}</div>
|
||||
<div class="day-temps">
|
||||
<span>{{ item.high }}</span>
|
||||
<span class="day-low">{{ item.low }}</span>
|
||||
</div>
|
||||
<div v-if="item.rain" class="day-rain">🌧 {{ item.rain }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
87
client/src/components/GraphModal.vue
Normal file
87
client/src/components/GraphModal.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS } from '../services/units'
|
||||
import MetricChart from './MetricChart.vue'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{ metricKey: string | null; currentData: DataRow }>()
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const meta = computed(() => props.metricKey ? METRICS[props.metricKey] : null)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.6);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div v-if="metricKey" class="backdrop" @click.self="emit('close')">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title">
|
||||
<span>{{ meta?.icon }}</span>
|
||||
<span>{{ meta?.label ?? metricKey }}</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="emit('close')">✕</button>
|
||||
</div>
|
||||
<MetricChart :metric-key="metricKey" :current-data="currentData" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
169
client/src/components/MapView.vue
Normal file
169
client/src/components/MapView.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Map from 'ol/Map'
|
||||
import View from 'ol/View'
|
||||
import TileLayer from 'ol/layer/Tile'
|
||||
import XYZ from 'ol/source/XYZ'
|
||||
import { fromLonLat } from 'ol/proj'
|
||||
import { current } from '../services/weather'
|
||||
import 'ol/ol.css'
|
||||
|
||||
const props = defineProps<{ dark: boolean }>()
|
||||
const mapEl = ref<HTMLDivElement>()
|
||||
let map: Map
|
||||
|
||||
const OVERLAYS = [
|
||||
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/coverage/0/256/{z}/{x}/{y}/1/1_1.png` },
|
||||
{ id: 'rain', label: 'Rain', icon: '🌧️', url: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png` },
|
||||
{ id: 'wind', label: 'Wind', icon: '💨', url: (_: number) => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
{ id: 'temp', label: 'Temperature', icon: '🌡️', url: (_: number) => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
{ id: 'pressure', label: 'Pressure', icon: '📊', url: (_: number) => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=demo` },
|
||||
]
|
||||
|
||||
const activeOverlays = ref<Set<string>>(new Set(['rain']))
|
||||
const radarTs = ref(Math.floor(Date.now() / 1000))
|
||||
const overlayLayers = new Map<string, TileLayer<XYZ>>()
|
||||
let radarInterval: ReturnType<typeof setInterval>
|
||||
|
||||
function buildBaseLayer(dark: boolean) {
|
||||
return new TileLayer({
|
||||
source: new XYZ({
|
||||
url: dark
|
||||
? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
|
||||
: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
|
||||
attributions: '© Stadia Maps © OpenMapTiles © OpenStreetMap',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function buildOverlayLayer(id: string): TileLayer<XYZ> {
|
||||
const def = OVERLAYS.find(o => o.id === id)!
|
||||
return new TileLayer({
|
||||
source: new XYZ({ url: def.url(radarTs.value), attributions: '' }),
|
||||
opacity: 0.6,
|
||||
zIndex: 10,
|
||||
})
|
||||
}
|
||||
|
||||
function toggleOverlay(id: string) {
|
||||
if (activeOverlays.value.has(id)) {
|
||||
activeOverlays.value.delete(id)
|
||||
const layer = overlayLayers.get(id)
|
||||
if (layer) { map.removeLayer(layer); overlayLayers.delete(id) }
|
||||
} else {
|
||||
activeOverlays.value.add(id)
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers.set(id, layer)
|
||||
map.addLayer(layer)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const lat = (current.value.gps_lat as number) || 0
|
||||
const lon = (current.value.gps_lon as number) || 0
|
||||
|
||||
map = new Map({
|
||||
target: mapEl.value!,
|
||||
layers: [buildBaseLayer(props.dark)],
|
||||
view: new View({
|
||||
center: fromLonLat([lon, lat]),
|
||||
zoom: 9,
|
||||
}),
|
||||
controls: [],
|
||||
})
|
||||
|
||||
// Add default active overlays
|
||||
for (const id of activeOverlays.value) {
|
||||
const layer = buildOverlayLayer(id)
|
||||
overlayLayers.set(id, layer)
|
||||
map.addLayer(layer)
|
||||
}
|
||||
|
||||
// Refresh radar every 5 min
|
||||
radarInterval = setInterval(() => {
|
||||
radarTs.value = Math.floor(Date.now() / 1000)
|
||||
const layer = overlayLayers.get('rain')
|
||||
if (layer) {
|
||||
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(radarTs.value) }))
|
||||
}
|
||||
}, 5 * 60 * 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => clearInterval(radarInterval))
|
||||
|
||||
watch(() => props.dark, (dark) => {
|
||||
const layers = map.getLayers()
|
||||
layers.setAt(0, buildBaseLayer(dark))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.map-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-el {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.overlay-toggles {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 100;
|
||||
background: var(--surface);
|
||||
border-radius: 99px;
|
||||
padding: 6px 10px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.overlay-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 99px;
|
||||
border: 1.5px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
|
||||
&.active {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.active) {
|
||||
background: var(--hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="map-wrap">
|
||||
<div ref="mapEl" class="map-el" />
|
||||
<div class="overlay-toggles">
|
||||
<button
|
||||
v-for="o in OVERLAYS"
|
||||
:key="o.id"
|
||||
class="overlay-btn"
|
||||
:class="{ active: activeOverlays.has(o.id) }"
|
||||
@click="toggleOverlay(o.id)"
|
||||
>
|
||||
{{ o.icon }} {{ o.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
76
client/src/components/MetricCard.vue
Normal file
76
client/src/components/MetricCard.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { METRICS, formatValue } from '../services/units'
|
||||
import type { DataRow } from '../services/api'
|
||||
|
||||
const props = defineProps<{
|
||||
metricKey: string
|
||||
data: DataRow
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'click', key: string): void }>()
|
||||
|
||||
const meta = computed(() => METRICS[props.metricKey])
|
||||
const value = computed(() => formatValue(props.metricKey, props.data[props.metricKey] as number | null))
|
||||
const label = computed(() => props.data[`${props.metricKey.replace(/_[^_]+$/, '')}_label`] as string | undefined)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
user-select: none;
|
||||
|
||||
&:hover { background: var(--hover); }
|
||||
&:active { transform: scale(0.97); }
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="card" @click="emit('click', metricKey)">
|
||||
<span class="icon">{{ meta?.icon ?? '📊' }}</span>
|
||||
<div class="info">
|
||||
<div class="metric-label">{{ meta?.label ?? metricKey }}</div>
|
||||
<div class="metric-value">{{ value }}</div>
|
||||
<div v-if="label" class="metric-sub">{{ label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
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