Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions src/lib/clipping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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);
}
}

Expand Down
9 changes: 7 additions & 2 deletions src/lib/components/selection/variable-selection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down Expand Up @@ -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}
>
Expand Down
33 changes: 33 additions & 0 deletions src/lib/components/settings/seamless-border-settings.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script lang="ts">
import { derived } from 'svelte/store';

import { preferences } from '$lib/stores/preferences';
import { selectedDomain } from '$lib/stores/variables';

import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';

import { updateSeamlessBorderLayer } from '$lib/layers';

// Only show this setting when a seamless domain is selected
const isSeamless = derived(selectedDomain, ($d) => 'layers' in $d);
const showBorders = $derived($preferences.showSeamlessBorders);
</script>

{#if $isSeamless}
<div>
<h2 class="text-lg font-bold">Seamless Borders</h2>
<div class="mt-3 flex gap-3 cursor-pointer">
<Switch
id="seamless-borders"
bind:checked={$preferences.showSeamlessBorders}
onCheckedChange={() => {
updateSeamlessBorderLayer();
}}
/>
<Label for="seamless-borders" class="cursor-pointer">
Domain borders {showBorders ? 'on' : 'off'}
</Label>
</div>
</div>
{/if}
2 changes: 2 additions & 0 deletions src/lib/components/settings/settings.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,6 +26,7 @@
<TileSizeSettings />
<PopupSettings />
<WaterClipSetting />
<SeamlessBorderSettings />
<OpacitySetting />
<CacheSettings />
<StateSettings />
Expand Down
4 changes: 2 additions & 2 deletions src/lib/components/time/time-selector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
150 changes: 150 additions & 0 deletions src/lib/layers.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';

Expand Down Expand Up @@ -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<GeoJSON.Polygon>[] = [];
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
// =============================================================================
Expand All @@ -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 => {
Expand All @@ -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();
};
23 changes: 18 additions & 5 deletions src/lib/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down Expand Up @@ -51,7 +63,8 @@ const fetchMetaData = async (

export const getMetaData = async (): Promise<DomainMetaDataJson> => {
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);
Expand All @@ -68,7 +81,7 @@ export const getMetaData = async (): Promise<DomainMetaDataJson> => {
? (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;
Expand Down
Loading