170 lines
4.6 KiB
Vue
170 lines
4.6 KiB
Vue
<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>
|