Files
weather-station/client/src/components/MapView.vue
2026-07-05 02:32:55 -04:00

374 lines
9.0 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/adsb.ts'
import { AISLayer } from '@/services/ais.ts'
import { api } from '@/services/api.ts'
import { RangeLayer } from '@/services/range.ts'
import VectorTileLayer from 'ol/layer/VectorTile'
import { 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 { defaults as defaultInteractions } from 'ol/interaction'
import { fromLonLat, toLonLat } from 'ol/proj'
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
import { applyStyle } from 'ol-mapbox-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 showWind = ref(false)
const windSrc = ref('')
const OVERLAYS = [
{ id: 'rain', label: 'Rain', icon: '🌧️' },
{ id: 'wind', label: 'Wind', icon: '💨' },
]
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
let aisLayer: AISLayer
let range: RangeLayer
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)
}
function showWindOverlay() {
syncWindSrc()
showWind.value = true
map.getInteractions().forEach(i => i.setActive(false))
}
function hideWindOverlay() {
showWind.value = false
map.getInteractions().forEach(i => i.setActive(true))
}
async function fetchRadarUrl(): Promise<string | null> {
try {
const res = await fetch('https://api.rainviewer.com/public/weather-maps.json')
const data = await res.json()
const past = data.radar?.past ?? []
const latest = past[past.length - 1]
if (!latest) return null
return `https://tilecache.rainviewer.com${latest.path}/256/{z}/{x}/{y}/2/1_1.png`
} catch {
return null
}
}
function buildRainLayer(url: string): TileLayer<XYZ> {
return new TileLayer({
source: new XYZ({
url,
maxZoom: 7,
tileLoadFunction: (tile: any, src: string) => {
const img = tile.getImage()
img.onerror = () => tile.setState(3)
img.src = src
},
}),
opacity: 0.2,
zIndex: 5,
})
}
async function refreshRain() {
const url = await fetchRadarUrl()
if (!url) return
if (overlayLayers['rain']) map.removeLayer(overlayLayers['rain'])
if (!activeOverlays.value.has('rain')) return
const layer = buildRainLayer(url)
overlayLayers['rain'] = layer
map.addLayer(layer)
}
function toggleAT() {
if (atActive.value) {
aisLayer.hide()
airTraffic.hide()
range.hide()
} else {
aisLayer.show()
airTraffic.show()
range.show()
}
atActive.value = !atActive.value
}
function toggleOverlay(id: string) {
if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind')
hideWindOverlay()
} else {
activeOverlays.value.add('wind')
showWindOverlay()
}
return
}
if (id === 'rain') {
if (activeOverlays.value.has('rain')) {
activeOverlays.value.delete('rain')
if (overlayLayers['rain']) { map.removeLayer(overlayLayers['rain']); delete overlayLayers['rain'] }
} else {
activeOverlays.value.add('rain')
refreshRain()
}
return
}
}
function buildBaseLayer(dark: boolean) {
const layer = new VectorTileLayer({ declutter: true })
applyStyle(layer, dark ? '/dark-theme.json' : '/light-theme.json')
return layer
}
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 () => {
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],
interactions: defaultInteractions({ altShiftDragRotate: false, pinchRotate: false }),
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
controls: [],
})
aisLayer = new AISLayer(map)
aisLayer.show()
airTraffic = new AirTrafficLayer(map)
airTraffic.show()
range = new RangeLayer(map)
range.show()
refreshRain()
radarInterval = setInterval(refreshRain, 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);
}
.desktop { display: flex; }
.mobile { display: none; }
@media (max-width: 768px) {
.desktop { display: none; }
.mobile { display: flex; }
.overlay-toggles { bottom: 10px; }
.layers-menu { bottom: 10px; }
}
</style>
<template>
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<iframe
v-if="showWind"
class="wind-iframe"
:src="windSrc"
frameborder="0"
allowfullscreen
/>
<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>
<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>