diff --git a/package-lock.json b/package-lock.json index 21b1da70..8232e97b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "devDependencies": { "@lucide/svelte": "^1.14.0", "@openmeteo/file-reader": "^0.0.16", - "@openmeteo/weather-map-layer": "github:open-meteo/weather-map-layer#e65e07029794285a64ec19f5f96e0e0c18a3e539", + "@openmeteo/weather-map-layer": "github:open-meteo/weather-map-layer#bae864da1e16b596b0232fc733dd54a73eb69676", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.59.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", @@ -675,8 +675,8 @@ }, "node_modules/@openmeteo/weather-map-layer": { "version": "0.0.19", - "resolved": "git+ssh://git@github.com/open-meteo/weather-map-layer.git#e65e07029794285a64ec19f5f96e0e0c18a3e539", - "integrity": "sha512-Ds6x5mYsa5XbYALlvoU2x8S9V1/6Y4oQjXy94qlsRoMGVIiMB3szxZpjx6z06DpUJdRvAzHiT1qXAPBXTrK9YQ==", + "resolved": "git+ssh://git@github.com/open-meteo/weather-map-layer.git#bae864da1e16b596b0232fc733dd54a73eb69676", + "integrity": "sha512-nhzww5pSdY+O50r8A9zBRYxxcqUfVqgHzbs1LfzGCZLgqO6+NIMVvspFVDWZ2ZPE0AEc0uR/ytFXH2PIWbhyQQ==", "dev": true, "dependencies": { "@mapbox/tilebelt": "^2.0.3", diff --git a/package.json b/package.json index 809b6859..4a7e1152 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "devDependencies": { "@lucide/svelte": "^1.14.0", "@openmeteo/file-reader": "^0.0.16", - "@openmeteo/weather-map-layer": "github:open-meteo/weather-map-layer#e65e07029794285a64ec19f5f96e0e0c18a3e539", + "@openmeteo/weather-map-layer": "github:open-meteo/weather-map-layer#bae864da1e16b596b0232fc733dd54a73eb69676", "@sveltejs/adapter-static": "^3.0.8", "@sveltejs/kit": "^2.59.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", diff --git a/src/lib/clipping.ts b/src/lib/clipping.ts index 4c05e7c7..a33d6e80 100644 --- a/src/lib/clipping.ts +++ b/src/lib/clipping.ts @@ -86,7 +86,9 @@ export const buildCountryClippingOptions = (countries: Country[]): ClippingOptio if (!geometry) return false; const lons: number[] = []; - const collect = (coords: any): void => { + const collect = ( + coords: number | number[] | number[][] | number[][][] | number[][][][] + ): void => { if (!Array.isArray(coords)) return; if (typeof coords[0] === 'number') { // a single position [lon, lat] @@ -102,7 +104,8 @@ export const buildCountryClippingOptions = (countries: Country[]): ClippingOptio collect(geometry.coordinates as number[][][][]); } else if (geometry.type === 'GeometryCollection') { for (const g of geometry.geometries) { - if (g.type === 'Polygon' || g.type === 'MultiPolygon') collect((g as any).coordinates); + if (g.type === 'Polygon') collect(g.coordinates); + else if (g.type === 'MultiPolygon') collect(g.coordinates); } } diff --git a/src/lib/components/selection/variable-selection.svelte b/src/lib/components/selection/variable-selection.svelte index d2233f27..9e263300 100644 --- a/src/lib/components/selection/variable-selection.svelte +++ b/src/lib/components/selection/variable-selection.svelte @@ -251,7 +251,9 @@ variant="outline" class="bg-glass/75 dark:bg-glass/75 backdrop-blur-sm shadow-md {variableSelectionOpen ? 'bg-glass/95!' - : ''} hover:bg-glass/95! h-7.25 w-45 cursor-pointer justify-between rounded border-none p-1.5! {domainSelectionOpen ? 'hidden' : ''}" + : ''} hover:bg-glass/95! h-7.25 w-45 cursor-pointer justify-between rounded border-none p-1.5! {domainSelectionOpen + ? 'hidden' + : ''}" role="combobox" aria-expanded={variableSelectionOpen} > @@ -381,7 +383,10 @@ variant="outline" class="bg-glass/75 dark:bg-glass/75 backdrop-blur-sm shadow-md {pressureLevelSelectionOpen ? 'bg-glass/95!' - : ''} hover:bg-glass/95! h-7.25 w-45 cursor-pointer justify-between rounded border-none p-1.5! {domainSelectionOpen || variableSelectionOpen ? 'hidden' : ''}" + : ''} hover:bg-glass/95! h-7.25 w-45 cursor-pointer justify-between rounded border-none p-1.5! {domainSelectionOpen || + variableSelectionOpen + ? 'hidden' + : ''}" role="combobox" aria-expanded={pressureLevelSelectionOpen} > diff --git a/src/lib/components/settings/seamless-border-settings.svelte b/src/lib/components/settings/seamless-border-settings.svelte new file mode 100644 index 00000000..639605a1 --- /dev/null +++ b/src/lib/components/settings/seamless-border-settings.svelte @@ -0,0 +1,33 @@ + + +{#if $isSeamless} +
+

Seamless Borders

+
+ { + updateSeamlessBorderLayer(); + }} + /> + +
+
+{/if} diff --git a/src/lib/components/settings/settings.svelte b/src/lib/components/settings/settings.svelte index 9d137dc6..d54626eb 100644 --- a/src/lib/components/settings/settings.svelte +++ b/src/lib/components/settings/settings.svelte @@ -9,6 +9,7 @@ import GridSettings from './grid-settings.svelte'; import OpacitySetting from './opacity-setting.svelte'; import PopupSettings from './popup-settings.svelte'; + import SeamlessBorderSettings from './seamless-border-settings.svelte'; import StateSettings from './state-settings.svelte'; import TileSizeSettings from './tile-size-settings.svelte'; import UnitSettings from './unit-settings.svelte'; @@ -25,6 +26,7 @@ + diff --git a/src/lib/components/time/time-selector.svelte b/src/lib/components/time/time-selector.svelte index 91a5b946..beba8509 100644 --- a/src/lib/components/time/time-selector.svelte +++ b/src/lib/components/time/time-selector.svelte @@ -181,7 +181,7 @@ const checkClosestModelRun = async () => { let timeStep = new Date($time); - let nearestModelRun = closestModelRun(timeStep, $selectedDomain.model_interval); + let nearestModelRun = closestModelRun(timeStep, $selectedDomain.model_interval!); if (nearestModelRun.getTime() > latestReferenceTime.getTime()) { nearestModelRun = latestReferenceTime; } @@ -194,7 +194,7 @@ toast.warning('Date selected too old, using 7 days ago time'); const nowTimeStep = domainStep( new Date(date7DaysAgo), - $selectedDomain.time_interval, + $selectedDomain.time_interval!, 'floor' ); time.set(nowTimeStep); diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 03b43cd4..1259f97a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -17,7 +17,8 @@ export const DEFAULT_PREFERENCES = { terrain: false, hillshade: false, clipWater: false, - showScale: true + showScale: true, + showSeamlessBorders: true }; // Layer names for map rendering diff --git a/src/lib/layers.ts b/src/lib/layers.ts index 6d92dffe..970df616 100644 --- a/src/lib/layers.ts +++ b/src/lib/layers.ts @@ -1,11 +1,13 @@ import { get } from 'svelte/store'; +import { type Domain, GridFactory, type SeamlessDomain } from '@openmeteo/weather-map-layer'; import * as maplibregl from 'maplibre-gl'; import { mode } from 'mode-watcher'; import { toast } from 'svelte-sonner'; import { map as m } from '$lib/stores/map'; import { loading, opacity, preferences as p } from '$lib/stores/preferences'; +import { selectedDomain } from '$lib/stores/variables'; import { vectorOptions as vO } from '$lib/stores/vector'; import { @@ -17,6 +19,7 @@ import { import { type SlotLayer, SlotManager } from '$lib/slot-manager'; import { refreshPopup } from './popup'; +import { omProtocolSettings } from './stores/om-protocol-settings'; import { currentOmUrl } from './stores/om-url'; import { getOMUrl } from './url'; @@ -285,6 +288,151 @@ export const createManagers = (): void => { }); }; +// ============================================================================= +// Seamless domain border overlay +// ============================================================================= + +const SEAMLESS_BORDER_SOURCE_ID = 'seamlessBorderSource'; + +const removeSeamlessBorderLayer = (): void => { + const map = get(m); + if (!map) return; + // Collect IDs first to avoid mutating the layer list while iterating + const toRemove = (map.getStyle()?.layers ?? []) + .map((l) => l.id) + .filter((id) => id.startsWith('seamless-border-')); + for (const id of toRemove) { + if (map.getLayer(id)) map.removeLayer(id); + } + if (map.getSource(SEAMLESS_BORDER_SOURCE_ID)) map.removeSource(SEAMLESS_BORDER_SOURCE_ID); +}; + +export const updateSeamlessBorderLayer = (): void => { + const map = get(m); + if (!map) return; + + removeSeamlessBorderLayer(); + + const preferences = get(p); + if (!preferences.showSeamlessBorders) return; + + const domain = get(selectedDomain); + if (!('layers' in domain)) return; // Not a seamless domain — nothing to draw + + const seamlessDomain = domain as SeamlessDomain; + const settings = get(omProtocolSettings); + + // Build a bounding-box polygon for each sub-layer except the global fallback + // (last layer), which covers the whole world and needs no border. + const features: GeoJSON.Feature[] = []; + for (let i = 0; i < seamlessDomain.layers.length - 1; i++) { + const layer = seamlessDomain.layers[i]; + const concreteDomain = settings.domainOptions.find( + (d) => d.value === layer.domainValue && !('layers' in d) + ) as Domain | undefined; + if (!concreteDomain) continue; + + const [minLon, minLat, maxLon, maxLat] = GridFactory.create( + concreteDomain.grid, + null + ).getBounds(); + features.push({ + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [minLon, minLat], + [maxLon, minLat], + [maxLon, maxLat], + [minLon, maxLat], + [minLon, minLat] + ] + ] + }, + properties: { + layerIndex: i, + minZoom: layer.minZoom, + label: concreteDomain.label ?? concreteDomain.value + } + }); + } + + if (features.length === 0) return; + + map.addSource(SEAMLESS_BORDER_SOURCE_ID, { + type: 'geojson', + data: { type: 'FeatureCollection', features } + }); + + // Add one line + one symbol MapLibre layer per boundary so each can carry its + // own zoom-dependent opacity that fades in 2 zoom levels before the sub-domain + // becomes active (i.e. when its minZoom threshold is reached by the user). + const lineColor = isDark() ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.5)'; + const textColor = isDark() ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.75)'; + const textHalo = isDark() ? 'rgba(0,0,0,0.5)' : 'rgba(255,255,255,0.5)'; + + for (const feature of features) { + const i = feature.properties!.layerIndex as number; + const minZoom = feature.properties!.minZoom as number; + // Start fading in 2 zoom levels before the layer becomes active + const fadeStart = Math.max(0, minZoom - 2); + + // When fadeStart === minZoom (only theoretically possible at minZoom 0), + // skip the interpolation and show at full opacity immediately. + const opacityExpr: maplibregl.ExpressionSpecification | number = + fadeStart < minZoom + ? (['interpolate', ['linear'], ['zoom'], fadeStart, 0, minZoom, 1] as const) + : 1; + + // Dashed bounding-box border + map.addLayer( + { + id: `seamless-border-line-${i}`, + type: 'line', + source: SEAMLESS_BORDER_SOURCE_ID, + minzoom: fadeStart, + filter: ['==', ['get', 'layerIndex'], i], + paint: { + 'line-color': lineColor, + 'line-width': 1.5, + 'line-dasharray': [4, 3], + 'line-opacity': opacityExpr + } + }, + BEFORE_LAYER_VECTOR + ); + + // Domain name label placed along the border line + map.addLayer( + { + id: `seamless-border-label-${i}`, + type: 'symbol', + source: SEAMLESS_BORDER_SOURCE_ID, + minzoom: fadeStart, + filter: ['==', ['get', 'layerIndex'], i], + layout: { + 'text-field': ['get', 'label'], + 'text-size': 11, + 'symbol-placement': 'line', + 'symbol-spacing': 400, + 'text-rotation-alignment': 'map', + 'text-offset': [0, -0.8], + 'text-allow-overlap': false, + 'text-ignore-placement': true + }, + paint: { + 'text-color': textColor, + 'text-halo-color': textHalo, + 'text-halo-width': 1.5, + 'text-opacity': opacityExpr + } + }, + BEFORE_LAYER_VECTOR + ); + } +}; + // ============================================================================= // Public layer API // ============================================================================= @@ -296,6 +444,7 @@ export const addOmFileLayers = (): void => { createManagers(); rasterManager?.update('om://' + omUrl); vectorManager?.update('om://' + omUrl); + updateSeamlessBorderLayer(); }; export const changeOMfileURL = (vectorOnly = false, rasterOnly = false): void => { @@ -316,4 +465,5 @@ export const changeOMfileURL = (vectorOnly = false, rasterOnly = false): void => if (!vectorOnly) rasterManager?.update('om://' + omUrl); if (!rasterOnly) vectorManager?.update('om://' + omUrl); + updateSeamlessBorderLayer(); }; diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts index a93ab38e..bc057e18 100644 --- a/src/lib/metadata.ts +++ b/src/lib/metadata.ts @@ -8,13 +8,25 @@ import { domain as d, selectedDomain, variable as v } from '$lib/stores/variable import { fmtModelRun, getBaseUri } from './helpers'; +/** For seamless domains, returns the last (global fallback) layer's domain value; + * for regular domains, returns the domain value unchanged. */ +const getMetaDomainValue = (domainValue: string): string => { + const domainObj = get(selectedDomain); + if (domainObj && 'layers' in domainObj && domainObj.layers.length > 0) { + return domainObj.layers[domainObj.layers.length - 1].domainValue; + } + return domainValue; +}; + export const getInitialMetaData = async () => { const domain = get(selectedDomain); - const uri = getBaseUri(domain.value); + const metaDomainValue = + 'layers' in domain ? domain.layers[domain.layers.length - 1].domainValue : domain.value; + const uri = getBaseUri(metaDomainValue); const [latestRes, inProgressRes] = await Promise.all([ - fetch(`${uri}/data_spatial/${domain.value}/latest.json`), - fetch(`${uri}/data_spatial/${domain.value}/in-progress.json`) + fetch(`${uri}/data_spatial/${metaDomainValue}/latest.json`), + fetch(`${uri}/data_spatial/${metaDomainValue}/in-progress.json`) ]); for (const res of [latestRes, inProgressRes]) { @@ -51,7 +63,8 @@ const fetchMetaData = async ( export const getMetaData = async (): Promise => { const domain = get(d); - const uri = getBaseUri(domain); + const metaDomain = getMetaDomainValue(domain); + const uri = getBaseUri(metaDomain); const latest = get(l); const latestReferenceTime = toDate(latest?.reference_time); @@ -68,7 +81,7 @@ export const getMetaData = async (): Promise => { ? (latest as DomainMetaDataJson) : matchesModelRun(inProgressReferenceTime, modelRun) ? (inProgress as DomainMetaDataJson) - : await fetchMetaData(uri, domain, modelRun); + : await fetchMetaData(uri, metaDomain, modelRun); result.valid_times.sort(); return result; diff --git a/src/lib/popup.ts b/src/lib/popup.ts index 8e830ef0..447a0a31 100644 --- a/src/lib/popup.ts +++ b/src/lib/popup.ts @@ -1,7 +1,9 @@ import { get } from 'svelte/store'; import { + type Domain, GridFactory, + type SeamlessDomain, createClippingTester, getCachedResolvedClipping, getColor, @@ -76,7 +78,32 @@ const updatePopupContent = async (coordinates: maplibregl.LngLat): Promise const activeUrl = rasterManager?.getActiveSourceUrl(); if (!activeUrl) return; - const { value } = await getValueFromLatLong(coordinates.lat, coordinates.lng, activeUrl); + const domain = get(selectedDomain); + let value: number; + if ('layers' in domain) { + // Seamless domain: try each sub-layer finest-first — states are stored + // under the concrete domain keys, not the seamless URL key. + const seamlessDomain = domain as SeamlessDomain; + value = NaN; + for (const layer of seamlessDomain.layers) { + const subLayerUrl = activeUrl.replace( + `/data_spatial/${seamlessDomain.value}/`, + `/data_spatial/${layer.domainValue}/` + ); + try { + const result = await getValueFromLatLong(coordinates.lat, coordinates.lng, subLayerUrl); + if (isFinite(result.value)) { + value = result.value; + break; + } + } catch { + // Sub-layer state not found (tile not yet loaded), try next + } + } + } else { + const result = await getValueFromLatLong(coordinates.lat, coordinates.lng, activeUrl); + value = result.value; + } if (isFinite(value)) { const omProtocolSettingsState = get(omProtocolSettings); @@ -116,7 +143,18 @@ const updatePopupContent = async (coordinates: maplibregl.LngLat): Promise contentDiv.style.backgroundColor = ''; contentDiv.style.color = ''; - const domainBounds = GridFactory.create(get(selectedDomain).grid).getBounds(); + const activeDomain = get(selectedDomain); + const concreteDomain: Domain = + 'layers' in activeDomain + ? (get(omProtocolSettings).domainOptions.find( + (d) => + d.value === + (activeDomain as SeamlessDomain).layers[ + (activeDomain as SeamlessDomain).layers.length - 1 + ].domainValue && !('layers' in d) + ) as Domain) + : (activeDomain as Domain); + const domainBounds = GridFactory.create(concreteDomain.grid).getBounds(); const [minLon, minLat, maxLon, maxLat] = domainBounds; const insideDomain = coordinates.lat >= minLat && diff --git a/src/lib/prefetch.ts b/src/lib/prefetch.ts index fb2b40d5..c8697d7b 100644 --- a/src/lib/prefetch.ts +++ b/src/lib/prefetch.ts @@ -8,7 +8,7 @@ import { MILLISECONDS_PER_DAY } from './constants'; import { fmtModelRun, fmtSelectedTime, getBaseUri } from './helpers'; import { selectedDomain } from './stores/variables'; -import type { DomainMetaDataJson } from '@openmeteo/weather-map-layer'; +import type { Domain, DomainMetaDataJson } from '@openmeteo/weather-map-layer'; export type PrefetchMode = 'today' | 'next24h' | 'prev24h' | 'completeModelRun'; @@ -121,7 +121,7 @@ export const prefetchData = async ( try { const instance = getProtocolInstance(get(omProtocolSettings)); - const ranges = getRanges(get(selectedDomain).grid, currentBounds); + const ranges = getRanges((get(selectedDomain) as Domain).grid, currentBounds); const omFileReader = instance.omFileReader; // Build base URL diff --git a/src/lib/stores/om-protocol-settings.ts b/src/lib/stores/om-protocol-settings.ts index de1a313b..1116d5af 100644 --- a/src/lib/stores/om-protocol-settings.ts +++ b/src/lib/stores/om-protocol-settings.ts @@ -1,10 +1,7 @@ import { type Writable, get, writable } from 'svelte/store'; import { BrowserBlockCache } from '@openmeteo/file-reader'; -import { - type WeatherMapLayerFileReader, - defaultOmProtocolSettings -} from '@openmeteo/weather-map-layer'; +import { WeatherMapLayerFileReader, defaultOmProtocolSettings } from '@openmeteo/weather-map-layer'; import { persisted } from 'svelte-persisted-store'; import { browser } from '$app/environment'; diff --git a/src/lib/stores/preferences.ts b/src/lib/stores/preferences.ts index eaedb2b9..aecc890a 100644 --- a/src/lib/stores/preferences.ts +++ b/src/lib/stores/preferences.ts @@ -42,6 +42,7 @@ export interface Preferences { hillshade: boolean; clipWater: boolean; showScale: boolean; + showSeamlessBorders: boolean; } export const preferences = persisted('preferences', defaultPreferences); diff --git a/src/lib/url.ts b/src/lib/url.ts index 5a2ae91c..18c80cc5 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -2,10 +2,12 @@ import { tick } from 'svelte'; import { get } from 'svelte/store'; import { + type AnyDomain, type Domain, type DomainMetaDataJson, closestModelRun, defaultOmProtocolSettings, + domainOptions, domainStep } from '@openmeteo/weather-map-layer'; import { mode } from 'mode-watcher'; @@ -211,9 +213,19 @@ export const getOMUrl = () => { export const getNextOmUrls = ( _omUrl: string, - domain: Domain, + anyDomain: AnyDomain, metaJson: DomainMetaDataJson | undefined ): [string | undefined, string | undefined] => { + // For seamless domains, resolve the last (global fallback) backing domain for + // URL construction and time-interval access. + const domain: Domain = + 'layers' in anyDomain + ? (domainOptions.find( + (d) => + d.value === anyDomain.layers[anyDomain.layers.length - 1].domainValue && + !('layers' in d) + ) as Domain) + : anyDomain; const base = `https://map-tiles.open-meteo.com/data_spatial/${domain.value}`; const date = get(time); const dateString = formatISOUTCWithZ(date); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index febc880c..4c901c58 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -3,7 +3,6 @@ import { get } from 'svelte/store'; import { - type Domain, GridFactory, domainOptions, omProtocol, @@ -56,6 +55,7 @@ import '../styles.css'; + import type { Domain } from '@openmeteo/weather-map-layer'; import type { RequestParameters } from 'maplibre-gl'; let clippingPanel: ReturnType; @@ -88,17 +88,28 @@ const style = await getStyle(); - const domainObject = domainOptions.find(({ value }: Domain) => value === $domain); + const domainObject = domainOptions.find(({ value }) => value === $domain); if (!domainObject) { throw new Error('Domain not found'); } - const grid = GridFactory.create(domainObject.grid); + // For seamless domains, use the global (last) backing domain for initial map position + const gridDomainValue = + 'layers' in domainObject + ? domainObject.layers[domainObject.layers.length - 1].domainValue + : domainObject.value; + const gridDomain = domainOptions.find(({ value }) => value === gridDomainValue) as + | Domain + | undefined; + if (!gridDomain) { + throw new Error('Backing domain not found'); + } + const grid = GridFactory.create(gridDomain.grid); $map = new maplibregl.Map({ container: mapContainer as HTMLElement, style: style, center: grid.getCenter(), - zoom: domainObject.grid.zoom, + zoom: gridDomain.grid.zoom, keyboard: false, hash: true, maxPitch: 85