This commit is contained in:
2026-07-05 02:32:55 -04:00
parent d4cdb15737
commit 11012afbe9
11 changed files with 971 additions and 226 deletions

View File

@@ -1,11 +1,10 @@
<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 VectorTileSource from 'ol/source/VectorTile';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
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'
@@ -18,27 +17,23 @@ 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 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: () => '' },
{ id: 'rain', label: 'Rain', icon: '🌧️' },
{ id: 'wind', label: 'Wind', icon: '💨' },
]
const activeOverlays = ref<Set<string>>(new Set(['rain']))
@@ -56,43 +51,65 @@ function buildWindSrc(lat: number, lon: number, zoom: number) {
}
function syncWindSrc() {
const view = map.getView()
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 showWindOverlay() {
syncWindSrc()
showWind.value = true
map.getInteractions().forEach(i => i.setActive(false))
}
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 hideWindOverlay() {
showWind.value = false
map.getInteractions().forEach(i => i.setActive(true))
}
function seekRadar(index: number) {
radarFrameIndex.value = index
const layer = overlayLayers['rain']
if (layer) {
layer.setSource(buildRainSource(OVERLAYS.find(o => o.id === 'rain')!.url()))
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) {
if (atActive.value) {
aisLayer.hide()
airTraffic.hide()
range.hide()
@@ -108,58 +125,42 @@ function toggleOverlay(id: string) {
if (id === 'wind') {
if (activeOverlays.value.has('wind')) {
activeOverlays.value.delete('wind')
showWind.value = false
hideWindOverlay()
} else {
activeOverlays.value.add('wind')
syncWindSrc()
showWind.value = true
showWindOverlay()
}
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)
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) {
return new VectorTileLayer({
source: new VectorTileSource({
url: dark
? 'https://tiles.zakscode.com/data/osm/{z}/{x}/{y}.pbf'
: 'https://tiles.zakscode.com/styles/light/512/{z}/{x}/{y}.png',
// ? 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}.png'
// : 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png',
}),
})
}
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,
})
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 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' }),
fill: new Fill({ color: 'transparent' }),
}))
source.addFeature(ring)
}
@@ -168,7 +169,7 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
dot.setStyle(new Style({
image: new CircleStyle({
radius: 7,
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
fill: new Fill({ color: dark ? '#ffffff' : '#000000' }),
stroke: new Stroke({ color: dark ? '#000000' : '#ffffff', width: 2 }),
}),
}))
@@ -178,48 +179,29 @@ function buildStationLayer(lat: number, lon: number, dark: boolean): VectorLayer
}
onMounted(async () => {
fetchRadarFrames().then(() => {
radarFrameIndex.value = nowcastStartIndex.value - 1
});
const position = await api.position();
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],
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 }),
view: new View({ center: fromLonLat([lon, lat]), zoom: 8, maxZoom: 13 }),
controls: [],
})
aisLayer = new AISLayer(map);
aisLayer.show();
aisLayer = new AISLayer(map)
aisLayer.show()
airTraffic = new AirTrafficLayer(map)
airTraffic.show()
range = new RangeLayer(map);
range.show();
range = new RangeLayer(map)
range.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)
refreshRain()
radarInterval = setInterval(refreshRain, 5 * 60 * 1000)
})
onUnmounted(() => {
@@ -332,80 +314,12 @@ watch(() => props.dark, dark => {
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; }
}
@@ -415,7 +329,6 @@ input[type='range'] {
<div class="map-wrap">
<div ref="mapEl" class="map-el" />
<!-- Windy animated wind iframe -->
<iframe
v-if="showWind"
class="wind-iframe"
@@ -424,7 +337,6 @@ input[type='range'] {
allowfullscreen
/>
<!-- Desktop toggles -->
<div class="overlay-toggles desktop">
<button class="overlay-btn" :class="{ active: atActive }" @click="toggleAT">
Traffic
@@ -439,7 +351,6 @@ input[type='range'] {
</button>
</div>
<!-- Mobile layers button + dropdown -->
<div class="layers-menu mobile">
<button class="layers-btn" @click="showOverlays = !showOverlays">
Layers