Updated UI

This commit is contained in:
2026-06-22 01:54:52 -04:00
parent 4300ebc532
commit 8fe35b820a
7 changed files with 464 additions and 168 deletions

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import {AirTrafficLayer} from '@/services/airtraffic.ts';
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { AirTrafficLayer } from '@/services/airtraffic.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'
@@ -10,59 +10,139 @@ 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 } from 'ol/proj'
import { fromLonLat, toLonLat } from 'ol/proj'
import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'
import { current } from '../services/weather'
import 'ol/ol.css'
const props = defineProps<{ dark: boolean }>()
const mapEl = ref<HTMLDivElement>()
const OWM_KEY = import.meta.env.VITE_OWM_API_KEY || ''
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: (ts: number) => `https://tilecache.rainviewer.com/v2/radar/${ts}/256/{z}/{x}/{y}/4/1_1.png`, needsTs: false },
{ id: 'clouds', label: 'Clouds', icon: '☁️', url: () => `https://tile.openweathermap.org/map/clouds_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
{ id: 'wind', label: 'Wind', icon: '💨', url: () => `https://tile.openweathermap.org/map/wind_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
{ id: 'temp', label: 'Temp', icon: '🌡️', url: () => `https://tile.openweathermap.org/map/temp_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
{ id: 'pressure', label: 'Pressure', icon: '📊', url: () => `https://tile.openweathermap.org/map/pressure_new/{z}/{x}/{y}.png?appid=${OWM_KEY}`, needsTs: false },
{ 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([]))
const radarTs = ref(Math.floor(Date.now() / 1000))
const overlayLayers: {[key: string]: any} = {}
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 = 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
radarFrameIndex.value = past.length - 1
}
function seekRadar(index: number) {
radarFrameIndex.value = index
const layer = overlayLayers['rain']
if (layer) {
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url(), maxZoom: 7 }))
}
}
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://{a-d}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{a-d}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
attributions: '© OpenStreetMap © CARTO',
maxZoom: 19,
? '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',
}),
zIndex: 0,
})
}
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,
source: new XYZ({ url: def.url(), attributions: '', maxZoom: 7 }),
opacity: 0.2,
zIndex: 5,
})
}
@@ -70,8 +150,7 @@ function buildOverlayLayer(id: string): TileLayer<XYZ> {
function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer<VectorSource> {
const center = fromLonLat([lon, lat])
const source = new VectorSource()
const ringColor = dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.18)'
const ringStroke = dark ? 'rgba(255,255,255,0.5)' : 'rgba(0,0,0,0.35)'
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))
@@ -95,20 +174,9 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
return new VectorLayer({ source, zIndex: 20 })
}
function toggleOverlay(id: string) {
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)
}
}
onMounted(async () => {
await fetchRadarFrames()
onMounted(() => {
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
@@ -117,7 +185,7 @@ onMounted(() => {
map = new Map({
target: mapEl.value!,
layers: [buildBaseLayer(props.dark), stationLayer],
view: new View({ center: fromLonLat([lon, lat]), zoom: 8 }),
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
controls: [],
})
@@ -125,17 +193,22 @@ onMounted(() => {
airTraffic.show()
for (const id of activeOverlays.value) {
if (id === 'wind') continue
const layer = buildOverlayLayer(id)
overlayLayers[id] = layer
map.addLayer(layer)
}
radarInterval = setInterval(() => {
radarTs.value = Math.floor(Date.now() / 1000)
// Sync windy src when OL map finishes moving
map.on('moveend', () => {
if (showWind.value) syncWindSrc()
})
radarInterval = setInterval(async () => {
await fetchRadarFrames()
const layer = overlayLayers['rain']
if (layer) {
const def = OVERLAYS.find(o => o.id === 'rain')!
layer.setSource(new XYZ({ url: def.url(radarTs.value) }))
layer.setSource(new XYZ({ url: OVERLAYS.find(o => o.id === 'rain')!.url() }))
}
}, 5 * 60 * 1000)
})
@@ -154,15 +227,17 @@ watch(() => props.dark, dark => {
map.addLayer(stationLayer)
})
let positioned = false
watch(() => current.value, () => {
const lat = (current.value.gps_lat as number) || 0
const lon = (current.value.gps_lon as number) || 0
map.getView().animate({ center: fromLonLat([lon, lat]), duration: 500 })
map.removeLayer(stationLayer)
stationLayer = buildStationLayer(lat, lon, props.dark)
map.addLayer(stationLayer)
if (!positioned) {
positioned = true
map.getView().animate({ center: fromLonLat([lon, lat]), duration: 500 })
map.removeLayer(stationLayer)
stationLayer = buildStationLayer(lat, lon, props.dark)
map.addLayer(stationLayer)
}
})
</script>
@@ -180,7 +255,15 @@ watch(() => current.value, () => {
height: 100%;
}
// Shared button style
.wind-iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
z-index: 10;
}
.overlay-btn {
display: flex;
align-items: center;
@@ -204,7 +287,6 @@ watch(() => current.value, () => {
&:hover:not(.active) { background: var(--hover); }
}
// Desktop bar
.overlay-toggles {
position: absolute;
bottom: 16px;
@@ -220,11 +302,10 @@ watch(() => current.value, () => {
backdrop-filter: blur(8px);
}
// Mobile layers menu
.layers-menu {
position: absolute;
bottom: 16px;
right: 16px;
right: 10px;
z-index: 100;
display: flex;
flex-direction: column;
@@ -255,13 +336,82 @@ watch(() => current.value, () => {
backdrop-filter: blur(8px);
}
// Visibility switching
.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: 70px; }
.layers-menu { bottom: 70px; }
}
</style>
@@ -269,8 +419,45 @@ watch(() => current.value, () => {
<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
/>
<!-- Radar Timeline -->
<div v-if="activeOverlays.has('rain') && radarFrames.length" class="radar-timeline">
<div class="slider-wrap">
<div class="slider-ticks">
<div
v-for="i in hourTickIndices" :key="i"
class="tick"
:class="{ nowcast: i >= nowcastStartIndex }"
:style="{ left: tickPercent(i) + '%' }"
>
<div class="tick__line" />
<span class="tick__label">{{ hourLabel(radarFrames[i]) }}</span>
</div>
</div>
<input
type="range"
:min="0"
:max="radarFrames.length - 1"
:value="radarFrameIndex"
@input="onSliderInput"
/>
</div>
<span class="timeline-label">{{ frameLabel(radarFrames[radarFrameIndex]) }}</span>
</div>
<!-- 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"
@@ -287,6 +474,9 @@ watch(() => current.value, () => {
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"
@@ -295,9 +485,6 @@ watch(() => current.value, () => {
>
{{ o.icon }} {{ o.label }}
</button>
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
</button>
</div>
</div>
</div>