weather_app/src/lib/stores/app.svelte.js
hermes-explorigin ff73e58d31 Add toggleable lightning strike layer to the radar map
- Lightning layer on PrecipitationRadar: toggleable  button (persisted via
  showLightning setting). When on, fetches recent strikes from the planned CORS
  relay and renders them as markers that pan/zoom with the slippy map.
- New src/lib/api/lightning.js: fetchLightningStrikes() reads
  https://cors.thecookiejar.me/lightning?lat&lon&radiusKm&limit (relay NOT
  deployed yet) with an 8s abort timeout and graceful failure - never blocks or
  crashes the map, shows an unavailable banner instead. Exports pure
  projectStrikeToLayer() for Mercator tile-layer marker projection.
- showLightning setting added to DEFAULT_SETTINGS + app store; toggle persists.
- Tests: fetch URL/parsing/error handling + layer projection. 71 total pass.
2026-08-27 00:30:25 +00:00

211 lines
5.5 KiB
JavaScript

/**
* App-level reactive state using Svelte 5 runes.
*/
import { getLocations, loadSettings } from '../storage/db.js'
import { fetchForecast } from '../api/weather.js'
import { fetchForecastNWS, mergeFallbackData } from '../api/nws.js'
export class AppStore {
// Location state
locations = $state([])
selectedLocationId = $state(null)
selectedLocation = $derived(
this.locations.find((l) => l.id === this.selectedLocationId) || null
)
// Forecast data
forecastData = $state(null)
loading = $state(false)
error = $state('')
// Geocoding
geocodingResults = $state([])
geocodingLoading = $state(false)
geocodingError = $state('')
// UI state
showAddLocation = $state(false)
showSettings = $state(false)
sidebarOpen = $state(false)
// Settings
settings = $state({
units: 'metric',
source: 'open-meteo',
refreshInterval: 30,
alertsEnabled: true,
showLightning: false, // toggleable lightning strike layer on the radar map
alertThresholds: {
precip: 70,
windGust: 40,
uvIndex: 6,
tempHigh: 35,
tempLow: 0,
thunderstorm: true,
},
})
/**
* Initialize the app: load locations + settings.
*/
async init() {
// Load settings first so we can use unit for forecasts
try {
this.settings = await loadSettings()
} catch (e) {
console.error('Failed to load settings:', e)
}
// Load locations
try {
this.locations = await getLocations()
} catch (e) {
console.error('Failed to load locations:', e)
}
// Load forecast for selected or first location
if (this.locations.length > 0) {
const current = this.locations.find((l) => l.isCurrent)
if (current) {
this.selectedLocationId = current.id
} else {
this.selectedLocationId = this.locations[0].id
}
await this.refreshForecast()
}
}
/**
* Refresh settings from DB.
*/
async refreshSettings() {
try {
this.settings = await loadSettings()
} catch (e) {
console.error('Failed to refresh settings:', e)
}
}
/**
* Refresh the forecasts list from DB.
*/
async refreshLocations() {
try {
this.locations = await getLocations()
} catch (e) {
console.error('Failed to refresh locations:', e)
}
}
/**
* Fetch forecast data for the currently selected location.
*/
async refreshForecast(locationId = null) {
const locId = locationId || this.selectedLocationId
if (!locId) return
const location = this.locations.find((l) => l.id === locId)
if (!location) return
this.loading = true
this.error = ''
try {
const units = this.settings.units
const source = this.settings.source || 'open-meteo'
let data
if (source === 'nws') {
try {
data = await fetchForecastNWS(location.lat, location.lon, units)
// NWS omits pressure + UV index — backfill those from Open-Meteo and
// flag them so the UI can tooltip that they came from the other source.
try {
const om = await fetchForecast(location.lat, location.lon, units)
data = mergeFallbackData(data, om)
} catch (omErr) {
console.warn('Open-Meteo fields unavailable:', omErr.message)
}
} catch (nwsErr) {
// NWS is US-only and can be unavailable — fall back to Open-Meteo
// so the app keeps working rather than breaking.
console.warn('NWS unavailable, falling back to Open-Meteo:', nwsErr.message)
data = await fetchForecast(location.lat, location.lon, units)
}
} else {
data = await fetchForecast(location.lat, location.lon, units)
}
this.forecastData = data
// Update the location name if we got a timezone abbreviation
if (data.timezone_abbreviation && !location.timezone) {
// We could update the location with timezone info, but skip for now
}
} catch (e) {
this.error = e.message || 'Failed to fetch weather data'
console.error('Forecast fetch error:', e)
} finally {
this.loading = false
}
}
/**
* Start (or restart) the auto-refresh timer based on the configured
* interval. A value of 0 (or missing) disables auto-refresh.
*/
startAutoRefresh() {
this.stopAutoRefresh()
const minutes = this.settings.refreshInterval
if (!minutes || minutes <= 0) return
this._refreshTimer = setInterval(() => {
this.refreshForecast()
}, minutes * 60 * 1000)
}
/**
* Stop the auto-refresh timer if it is running.
*/
stopAutoRefresh() {
if (this._refreshTimer) {
clearInterval(this._refreshTimer)
this._refreshTimer = null
}
}
/**
* Select a location and fetch its forecast.
* @param {string} id
*/
async selectLocation(id) {
this.selectedLocationId = id
this.sidebarOpen = false
await this.refreshForecast(id)
}
/**
* Search locations via geocoding API.
* @param {string} query
*/
async searchGeocoding(query) {
if (!query || query.length < 2) {
this.geocodingResults = []
return
}
this.geocodingLoading = true
this.geocodingError = ''
try {
const { searchLocations } = await import('../api/weather.js')
this.geocodingResults = await searchLocations(query)
} catch (e) {
this.geocodingError = e.message || 'Search failed'
this.geocodingResults = []
} finally {
this.geocodingLoading = false
}
}
}
export const app = new AppStore()