Files
weather-station/client/src/components/MapView.vue
2026-06-25 10:21:55 -04:00

467 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { AirTrafficLayer } from '@/services/airtraffic.ts'
import {api} from '@/services/api.ts';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import Map from 'ol/Map'
import View from 'ol/View'
import TileLayer from 'ol/layer/Tile'
import VectorLayer from 'ol/layer/Vector'
import VectorSource from 'ol/source/Vector'
import XYZ from 'ol/source/XYZ'
import Feature from 'ol/Feature'
import Point from 'ol/geom/Point'
import CircleGeom from 'ol/geom/Circle'
import { fromLonLat, toLonLat } from 'ol/proj'
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
import 'ol/ol.css'
const current = ref({latitude: 0, longitude: 0});
const props = defineProps<{ dark: boolean }>()
const mapEl = ref<HTMLDivElement>()
const showOverlays = ref(false)
const atActive = ref(true)
const NM = 1852
const radarFrames = ref<{ path: string; time: number }[]>([])
const radarFrameIndex = ref(0)
const radarPath = computed(() => radarFrames.value[radarFrameIndex.value]?.path ?? '')
const nowcastStartIndex = ref(0)
const showWind = ref(false)
const windSrc = ref('')
const OVERLAYS = [
{ id: 'rain', label: 'Rain', icon: '🌧️', url: () => `https://tilecache.rainviewer.com${radarPath.value}/256/{z}/{x}/{y}/2/1_1.png` },
{ id: 'wind', label: 'Wind', icon: '💨', url: () => '' },
]
const activeOverlays = ref<Set<string>>(new Set(['rain']))
const overlayLayers: { [key: string]: any } = {}
let map: Map
let stationLayer: VectorLayer<VectorSource>
let radarInterval: ReturnType<typeof setInterval>
let airTraffic: AirTrafficLayer
const hourTickIndices = computed(() => {
const indices: number[] = []
let lastHour = -1
radarFrames.value.forEach((f, i) => {
const h = new Date(f.time * 1000).getHours()
if (h !== lastHour) { indices.push(i); lastHour = h }
})
return indices
})
function buildWindSrc(lat: number, lon: number, zoom: number) {
return `https://embed.windy.com/embed2.html?lat=${lat.toFixed(4)}&lon=${lon.toFixed(4)}&detailLat=${lat.toFixed(4)}&detailLon=${lon.toFixed(4)}&zoom=${Math.round(zoom)}&level=surface&overlay=wind&product=ecmwf&menu=&message=&marker=&calendar=now&pressure=&type=map&location=coordinates&detail=&metricWind=kt&metricTemp=%C2%B0C&radarRange=-1`
}
function syncWindSrc() {
const view = map.getView()
const center: any = toLonLat(view.getCenter()!)
windSrc.value = buildWindSrc(center[1], center[0], view.getZoom() ?? 8)
}
async function fetchRadarFrames() {
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
const data = await res.json()
const past = data.radar.past ?? []
const nowcast = data.radar.nowcast ?? []
radarFrames.value = [...past, ...nowcast]
nowcastStartIndex.value = past.length
}
function buildRainSource(url: string): XYZ {
return new XYZ({
url,
attributions: '',
maxZoom: 7,
tileLoadFunction: (tile: any, src: string) => {
const img = tile.getImage()
img.onerror = () => tile.setState(3)
img.src = src
},
})
}
function seekRadar(index: number) {
radarFrameIndex.value = index
const layer = overlayLayers['rain']
if (layer) {
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
}
}
function onSliderInput(e: Event) {
seekRadar(Number((e.target as HTMLInputElement).value))
}
function frameLabel(frame: { time: number }) {
return new Date(frame.time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
}
function hourLabel(frame: { time: number }) {
return new Date(frame.time * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
}
function tickPercent(index: number) {
return (index / (radarFrames.value.length - 1)) * 100
}
function toggleAT() {
atActive.value ? airTraffic.hide() : airTraffic.show()
atActive.value = !atActive.value
}
function toggleOverlay(id: string) {
if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind')
showWind.value = false
} else {
activeOverlays.value.add('wind')
syncWindSrc()
showWind.value = true
}
return
}
if (activeOverlays.value.has(id)) {
activeOverlays.value.delete(id)
const layer = overlayLayers[id]
if (layer) { map.removeLayer(layer); delete overlayLayers[id] }
} else {
activeOverlays.value.add(id)
const layer = buildOverlayLayer(id)
overlayLayers[id] = layer
map.addLayer(layer)
}
}
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: buildRainSource(def.url()),
opacity: 0.2,
zIndex: 5,
})
}
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
const center = fromLonLat([lon, lat])
const source = new VectorSource()
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
for (const nm of [50, 100, 150, 200]) {
const ring = new Feature(new CircleGeom(center, nm * NM))
ring.setStyle(new Style({
stroke: new Stroke({ color: ringStroke, width: 1, lineDash: [6, 4] }),
fill: new Fill({ color: 'transparent' }),
}))
source.addFeature(ring)
}
const dot = new Feature(new Point(center))
dot.setStyle(new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
stroke: new Stroke({ color: dark ? '#000000' : '#ffffff', width: 2 }),
}),
}))
source.addFeature(dot)
return new VectorLayer({ source, zIndex: 20 })
}
onMounted(async () => {
await fetchRadarFrames()
radarFrameIndex.value = nowcastStartIndex.value - 1
const position = await api.position();
const lat = position?.latitude || 0
const lon = position?.longitude || 0
stationLayer = buildStationLayer(lat, lon, props.dark)
map = new Map({
target: mapEl.value!,
layers: [buildBaseLayer(props.dark), stationLayer],
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
controls: [],
})
airTraffic = new AirTrafficLayer(map)
airTraffic.show()
for (const id of activeOverlays.value) {
if (id === 'wind') continue
const layer = buildOverlayLayer(id)
overlayLayers[id] = layer
map.addLayer(layer)
}
map.on('moveend', () => {
if (showWind.value) syncWindSrc()
})
radarInterval = setInterval(async () => {
await fetchRadarFrames()
radarFrameIndex.value = nowcastStartIndex.value - 1
seekRadar(radarFrameIndex.value)
}, 5 * 60 * 1000)
})
onUnmounted(() => {
clearInterval(radarInterval)
airTraffic.hide()
})
watch(() => props.dark, dark => {
map.getLayers().setAt(0, buildBaseLayer(dark))
map.removeLayer(stationLayer)
const lat = (current.value.latitude as number) || 0
const lon = (current.value.longitude as number) || 0
stationLayer = buildStationLayer(lat, lon, dark)
map.addLayer(stationLayer)
})
</script>
<style scoped lang="scss">
.map-wrap {
position: relative;
width: 100%;
height: 100%;
border-radius: 12px;
overflow: hidden;
}
.map-el {
width: 100%;
height: 100%;
}
.wind-iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
z-index: 10;
}
.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); }
}
.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);
backdrop-filter: blur(8px);
}
.layers-menu {
position: absolute;
bottom: 16px;
right: 10px;
z-index: 100;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.layers-btn {
padding: 8px 14px;
border-radius: 99px;
border: 1.5px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 13px;
cursor: pointer;
backdrop-filter: blur(8px);
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
}
.layers-dropdown {
display: flex;
flex-direction: column;
gap: 6px;
background: var(--surface);
border-radius: 12px;
padding: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
}
.radar-timeline {
position: absolute;
bottom: 72px;
left: 50%;
transform: translateX(-50%);
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border-radius: 16px;
padding: 8px 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
backdrop-filter: blur(8px);
width: min(600px, calc(100vw - 32px));
}
.slider-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.slider-ticks {
position: relative;
height: 16px;
}
.tick {
position: absolute;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
&__line {
width: 1px;
height: 5px;
background: var(--border);
}
&__label {
font-size: 9px;
color: var(--text-muted, #888);
white-space: nowrap;
}
&.nowcast &__line { background: #6ab4ff; }
&.nowcast &__label { color: #6ab4ff; }
}
input[type='range'] {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.timeline-label {
font-size: 12px;
color: var(--text);
min-width: 42px;
text-align: right;
flex-shrink: 0;
}
.desktop { display: flex; }
.mobile { display: none; }
@media (max-width: 768px) {
.desktop { display: none; }
.mobile { display: flex; }
.radar-timeline { bottom: 10px; }
.overlay-toggles { bottom: 10px; }
.layers-menu { bottom: 10px; }
}
</style>
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<!-- Windy animated wind iframe -->
<iframe
v-if="showWind"
class="wind-iframe"
:src="windSrc"
frameborder="0"
allowfullscreen
/>
<!-- Desktop toggles -->
<div class="overlay-toggles desktop">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
</button>
<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>
<!-- Mobile layers button + dropdown -->
<div class="layers-menu mobile">
<button class="layers-btn" @click="showOverlays = !showOverlays">
Layers
</button>
<div v-if="showOverlays" class="layers-dropdown">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
</button>
<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>
</div>
</template>