nearby
This commit is contained in:
@@ -18,6 +18,8 @@
|
||||
/>
|
||||
-->
|
||||
<script module lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
export interface ChartSeries {
|
||||
/** Series display name (used in legend and tooltip) */
|
||||
name: string;
|
||||
@@ -698,7 +700,11 @@
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillStyle = muted;
|
||||
ctx.globalAlpha = 0.8;
|
||||
ctx.fillText('Weather data by Open-Meteo · visualisation by Drizz.li', width - 6, totalH - 5);
|
||||
ctx.fillText(
|
||||
`${m.footer_data_by()} Open-Meteo · ${m.chart_credit_viz()} Drizz.li`,
|
||||
width - 6,
|
||||
totalH - 5
|
||||
);
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
@@ -1764,7 +1770,7 @@
|
||||
? 'opacity-40'
|
||||
: ''}"
|
||||
onclick={() => toggleSeries(s.name)}
|
||||
title="Toggle {s.name}"
|
||||
title={m.chart_toggle_series({ series: s.name })}
|
||||
>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 shrink-0 rounded-full"
|
||||
@@ -1783,12 +1789,13 @@
|
||||
<div
|
||||
class="px-2 pt-2 pb-1 text-center md:text-right text-[10px] leading-none text-muted-foreground/70"
|
||||
>
|
||||
Weather data by <a
|
||||
{m.footer_data_by()}
|
||||
<a
|
||||
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||
href="https://open-meteo.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">Open-Meteo</a
|
||||
>, visualisation by
|
||||
>, {m.chart_credit_viz()}
|
||||
<a
|
||||
class="font-medium underline-offset-2 hover:text-foreground hover:underline"
|
||||
href="https://drizz.li"
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||
@@ -93,7 +95,7 @@
|
||||
>
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
<span class="sr-only">Loading charts...</span>
|
||||
<span class="sr-only">{m.charts_loading()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
</ChartToolbar>
|
||||
-->
|
||||
<script module lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
export type { ExportableChart } from './downloadChartsPng';
|
||||
</script>
|
||||
|
||||
@@ -87,7 +89,7 @@
|
||||
class="toolbar-btn"
|
||||
disabled={!hasCharts || downloading}
|
||||
onclick={handleDownload}
|
||||
title="Download meteogram as PNG image"
|
||||
title={m.chart_download()}
|
||||
>
|
||||
{#if downloading}
|
||||
<svg
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
export let label: string = 'Search location...';
|
||||
export let placeholder: string = 'Enter city name...';
|
||||
|
||||
@@ -161,8 +163,8 @@
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground/50'}"
|
||||
onclick={() => toggleFavorite(location)}
|
||||
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
aria-label={fav ? m.search_favorite_remove() : m.search_favorite_add()}
|
||||
title={fav ? m.search_favorite_remove() : m.search_favorite_add()}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -182,8 +184,8 @@
|
||||
<button
|
||||
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground/50 hover:bg-background hover:text-destructive"
|
||||
onclick={() => removeRecent(location)}
|
||||
aria-label="Remove {location.name} from recent locations"
|
||||
title="Remove from recent"
|
||||
aria-label={m.search_remove_recent({ location: location.name })}
|
||||
title={m.search_remove_recent_short()}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
||||
<path stroke-linecap="round" d="M6 6l12 12M18 6L6 18" />
|
||||
@@ -231,7 +233,7 @@
|
||||
class="h-9"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-label="Search Location"
|
||||
aria-label={m.search_aria()}
|
||||
bind:value={searchQuery}
|
||||
bind:ref={searchInputEl}
|
||||
/>
|
||||
@@ -240,7 +242,7 @@
|
||||
variant="outline"
|
||||
size="default"
|
||||
class="h-9 px-2.5"
|
||||
title="Use GPS Location"
|
||||
title={m.search_gps()}
|
||||
onclick={() => (searchQuery = 'GPS')}
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -266,7 +268,7 @@
|
||||
<div class="flex h-20 items-center justify-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<span class="text-sm text-muted-foreground">Searching...</span>
|
||||
<span class="text-sm text-muted-foreground">{m.search_searching()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:then results}
|
||||
@@ -276,7 +278,7 @@
|
||||
<div
|
||||
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Favorites
|
||||
{m.search_favorites()}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||
@@ -290,7 +292,7 @@
|
||||
? 'mt-3'
|
||||
: ''}"
|
||||
>
|
||||
Recent
|
||||
{m.search_recent()}
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each recentToShow as loc (locationKey(loc))}
|
||||
@@ -316,7 +318,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-xs">
|
||||
Start typing to search or use GPS to detect your position
|
||||
{m.search_hint()}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -336,7 +338,7 @@
|
||||
</Alert.Root>
|
||||
{:else}
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Description>No locations found</Alert.Description>
|
||||
<Alert.Description>{m.search_no_results()}</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{:catch error}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import SupporterIcon from './SupporterIcon.svelte';
|
||||
import UnlockDialog from './UnlockDialog.svelte';
|
||||
import { SIGNUP_URL, SUPPORTER_PERKS, getSupporterPrice } from './config';
|
||||
@@ -8,11 +10,11 @@
|
||||
|
||||
interface Props {
|
||||
/** Short feature name shown in the locked panel headline. */
|
||||
feature?: string;
|
||||
feature: string;
|
||||
children: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { feature = 'This page', children }: Props = $props();
|
||||
let { feature, children }: Props = $props();
|
||||
|
||||
let unlockOpen = $state(false);
|
||||
const price = getSupporterPrice();
|
||||
@@ -31,7 +33,7 @@
|
||||
<svg class="mr-2 h-4 w-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-width="2" d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
Checking your subscription…
|
||||
{m.supporter_checking()}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
@@ -43,14 +45,13 @@
|
||||
<SupporterIcon class="h-7 w-7" />
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-bold tracking-tight">{feature} is a supporter extra</h2>
|
||||
<h2 class="text-xl font-bold tracking-tight">{m.supporter_gate_title({ feature })}</h2>
|
||||
<p class="mx-auto mt-1.5 max-w-sm text-sm text-muted-foreground">
|
||||
Drizz.li is free and open source. Chip in from {price} to keep it running - as a thank-you, supporters
|
||||
unlock the extras.
|
||||
{m.supporter_gate_body({ price })}
|
||||
</p>
|
||||
|
||||
<ul class="mx-auto mt-5 grid max-w-sm gap-2 text-left">
|
||||
{#each SUPPORTER_PERKS as perk (perk)}
|
||||
{#each SUPPORTER_PERKS() as perk (perk)}
|
||||
<li class="flex items-start gap-2.5 text-sm">
|
||||
<svg
|
||||
class="mt-0.5 h-4 w-4 shrink-0 text-primary"
|
||||
@@ -73,20 +74,20 @@
|
||||
rel="noopener"
|
||||
class="inline-flex h-10 w-full items-center justify-center rounded-lg bg-primary px-5 text-sm font-semibold text-primary-foreground transition-colors hover:bg-primary/90 sm:w-auto"
|
||||
>
|
||||
Become a supporter
|
||||
{m.supporter_become()}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-10 w-full cursor-pointer items-center justify-center rounded-lg border border-border bg-background px-5 text-sm font-semibold text-foreground transition-colors hover:bg-muted sm:w-auto"
|
||||
onclick={() => (unlockOpen = true)}
|
||||
>
|
||||
I have a key
|
||||
{m.supporter_have_key()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $supporterState.status === 'invalid'}
|
||||
<p class="mt-4 text-xs text-amber-600 dark:text-amber-400">
|
||||
Your saved key is no longer valid or has expired.
|
||||
{m.supporter_key_expired()}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { SIGNUP_URL, getSupporterPrice } from './config';
|
||||
import {
|
||||
clearLicense,
|
||||
@@ -46,7 +48,7 @@
|
||||
// close shortly so the success state is visible for a beat
|
||||
setTimeout(() => (open = false), 700);
|
||||
} else {
|
||||
localError = result.error ?? 'That key is not valid or has expired.';
|
||||
localError = result.error ?? m.supporter_key_invalid();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,17 +60,17 @@
|
||||
let expiresLabel = $derived.by(() => {
|
||||
const exp = $supporterState.expires;
|
||||
if ($supporterState.status !== 'valid') return null;
|
||||
if (!exp) return 'Lifetime access';
|
||||
return `Active until ${formatZoned(new Date(exp), 'UTC', 'd LLL yyyy')}`;
|
||||
if (!exp) return m.supporter_lifetime();
|
||||
return m.supporter_active_until({ date: formatZoned(new Date(exp), 'UTC', 'd LLL yyyy') });
|
||||
});
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="sm:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Drizz.li Supporter</Dialog.Title>
|
||||
<Dialog.Title>{m.supporter_dialog_title()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
Paste the access key from your supporter email to unlock the extras.
|
||||
{m.supporter_dialog_desc()}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
@@ -87,7 +89,7 @@
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="font-semibold">Supporter extras are active</p>
|
||||
<p class="font-semibold">{m.supporter_extras_active()}</p>
|
||||
{#if expiresLabel}<p class="text-xs opacity-80">{expiresLabel}</p>{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +97,7 @@
|
||||
|
||||
<form onsubmit={submit} class="grid gap-3">
|
||||
<div class="grid gap-1.5">
|
||||
<Label for="license-key">Access key</Label>
|
||||
<Label for="license-key">{m.supporter_access_key()}</Label>
|
||||
<Input
|
||||
id="license-key"
|
||||
bind:value={keyInput}
|
||||
@@ -110,12 +112,12 @@
|
||||
<p class="text-sm text-destructive">{localError}</p>
|
||||
{:else if $supporterState.status === 'error'}
|
||||
<p class="text-sm text-destructive">
|
||||
Couldn't reach the server. Check your connection and try again.
|
||||
{m.supporter_server_unreachable()}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" disabled={submitting || !keyInput.trim()}>
|
||||
{#if submitting}Verifying…{:else}Unlock{/if}
|
||||
{#if submitting}{m.supporter_verifying()}{:else}{m.supporter_unlock_button()}{/if}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
@@ -126,18 +128,18 @@
|
||||
class="cursor-pointer text-center text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
onclick={removeKey}
|
||||
>
|
||||
Remove key from this device
|
||||
{m.supporter_remove_key()}
|
||||
</button>
|
||||
{:else}
|
||||
<p class="text-center text-xs text-muted-foreground">
|
||||
No key yet?
|
||||
{m.supporter_no_key_yet()}
|
||||
<a
|
||||
href={SIGNUP_URL}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="font-semibold text-primary underline-offset-2 hover:underline"
|
||||
>
|
||||
Support the project from {price}
|
||||
{m.supporter_support_from({ price })}
|
||||
</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
/**
|
||||
* Paywall configuration.
|
||||
*
|
||||
@@ -63,9 +65,10 @@ export function getSupporterPrice(): string {
|
||||
return currency === 'CHF' ? `${symbol} 3 / month` : `${symbol}3 / month`;
|
||||
}
|
||||
|
||||
/** Short, human list of what supporting unlocks (shown on the locked panel). */
|
||||
export const SUPPORTER_PERKS = [
|
||||
'Historical weather & climate-normal comparisons',
|
||||
'Seasonal outlook: months ahead vs the climate normal',
|
||||
'New supporter extras as they land'
|
||||
/** Short, human list of what supporting unlocks (shown on the locked panel).
|
||||
* A function, not a constant: the copy has to follow the active locale. */
|
||||
export const SUPPORTER_PERKS = (): string[] => [
|
||||
m.supporter_perk_historical(),
|
||||
m.supporter_perk_seasonal(),
|
||||
m.supporter_perk_future()
|
||||
];
|
||||
|
||||
@@ -14,6 +14,8 @@ import { derived, get, writable } from 'svelte/store';
|
||||
|
||||
import { persisted } from 'svelte-persisted-store';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { PAYWALL_API_BASE } from './config';
|
||||
|
||||
/** The supporter's access key, e.g. "DRZ-7Q2K-9F4M-XW3P". Empty when signed out. */
|
||||
@@ -99,7 +101,7 @@ export async function verifyKey(key: string): Promise<VerifyResult> {
|
||||
const trimmed = key.trim();
|
||||
if (!trimmed) {
|
||||
supporterState.set({ status: 'invalid' });
|
||||
return { valid: false, error: 'Enter your access key.' };
|
||||
return { valid: false, error: m.supporter_enter_key() };
|
||||
}
|
||||
|
||||
supporterState.set({ status: 'checking' });
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Picks a handful of cities around a location, for cross-referencing a forecast
|
||||
* against places you already have a feel for.
|
||||
*
|
||||
* Open-Meteo's geocoding API can only search by name - there is no radius or
|
||||
* reverse lookup - so the candidates come from a static GeoNames extract, cut
|
||||
* into 10x10 degree tiles (see scripts/build-cities.mjs). Looking somewhere up
|
||||
* costs one small tile fetch, which the browser then caches.
|
||||
*/
|
||||
import { base } from '$app/paths';
|
||||
|
||||
/** [geonames id, name, country code, latitude, longitude, population/1000] */
|
||||
type CityRow = [number, string, string, number, number, number];
|
||||
|
||||
export interface NearbyCity {
|
||||
id: number;
|
||||
name: string;
|
||||
countryCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
population: number;
|
||||
distanceKm: number;
|
||||
}
|
||||
|
||||
const EARTH_RADIUS_KM = 6371;
|
||||
const TILE_DEGREES = 10;
|
||||
|
||||
export function distanceKm(lat1: number, lon1: number, lat2: number, lon2: number): number {
|
||||
const toRad = Math.PI / 180;
|
||||
const dLat = (lat2 - lat1) * toRad;
|
||||
const dLon = (lon2 - lon1) * toRad;
|
||||
const a =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(lat1 * toRad) * Math.cos(lat2 * toRad) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(a)));
|
||||
}
|
||||
|
||||
function tileKey(latitude: number, longitude: number): string {
|
||||
const lat = Math.min(89.999, Math.max(-90, latitude));
|
||||
const lon = ((((longitude + 180) % 360) + 360) % 360) - 180;
|
||||
return `${Math.floor((lat + 90) / TILE_DEGREES)}_${Math.floor((lon + 180) / TILE_DEGREES)}`;
|
||||
}
|
||||
|
||||
const tileCache = new Map<string, Promise<CityRow[]>>();
|
||||
let indexPromise: Promise<Set<string>> | null = null;
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${base}/data/cities/${path}`);
|
||||
if (!res.ok) throw new Error(`${path}: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Tiles exist only where there are cities, so the index turns an empty ocean
|
||||
* tile into a no-op instead of a 404. */
|
||||
function loadIndex(): Promise<Set<string>> {
|
||||
indexPromise ??= fetchJson<string[]>('index.json')
|
||||
.then((keys) => new Set(keys))
|
||||
.catch((err) => {
|
||||
indexPromise = null;
|
||||
throw err;
|
||||
});
|
||||
return indexPromise;
|
||||
}
|
||||
|
||||
async function loadTile(key: string): Promise<CityRow[]> {
|
||||
if (!(await loadIndex()).has(key)) return [];
|
||||
|
||||
let tile = tileCache.get(key);
|
||||
if (!tile) {
|
||||
tile = fetchJson<CityRow[]>(`${key}.json`).catch((err) => {
|
||||
// a failed fetch must not poison the cache: the next visit retries
|
||||
tileCache.delete(key);
|
||||
throw err;
|
||||
});
|
||||
tileCache.set(key, tile);
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Widening rings. Within 400 km the tile holds towns down to 20k; past that
|
||||
* only cities above 300k, which is exactly the bias a wide search wants - if
|
||||
* nothing is nearby, the answer should be a place people have heard of.
|
||||
*/
|
||||
const SEARCH_RADII_KM = [200, 400, 1500];
|
||||
|
||||
/**
|
||||
* Returns up to `count` cities around the given point, ordered by distance.
|
||||
*
|
||||
* Candidates are ranked by population damped by distance, so a town up the
|
||||
* valley can outrank a metropolis three hours away, and picks have to keep
|
||||
* their distance from each other - otherwise a place like New York fills the
|
||||
* whole list with its own boroughs.
|
||||
*/
|
||||
export async function findNearbyCities(
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
count = 10
|
||||
): Promise<NearbyCity[]> {
|
||||
const rows = await loadTile(tileKey(latitude, longitude));
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
let best: NearbyCity[] = [];
|
||||
|
||||
for (const radius of SEARCH_RADII_KM) {
|
||||
const halfWeightKm = radius / 4;
|
||||
// far-apart picks in a wide search, tight ones when everything is close
|
||||
const minSeparationKm = Math.max(15, radius / 20);
|
||||
|
||||
const scored = rows
|
||||
.map(([id, name, countryCode, lat, lon, popK]) => {
|
||||
const dist = distanceKm(latitude, longitude, lat, lon);
|
||||
return {
|
||||
city: {
|
||||
id,
|
||||
name,
|
||||
countryCode,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
population: popK * 1000,
|
||||
distanceKm: dist
|
||||
},
|
||||
score: popK / (1 + (dist / halfWeightKm) ** 2)
|
||||
};
|
||||
})
|
||||
.filter(({ city }) => city.distanceKm >= minSeparationKm && city.distanceKm <= radius)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const picked: NearbyCity[] = [];
|
||||
for (const { city } of scored) {
|
||||
if (picked.length === count) break;
|
||||
const tooClose = picked.some(
|
||||
(p) => distanceKm(p.latitude, p.longitude, city.latitude, city.longitude) < minSeparationKm
|
||||
);
|
||||
if (!tooClose) picked.push(city);
|
||||
}
|
||||
|
||||
if (picked.length >= count) return picked.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||
if (picked.length > best.length) best = picked;
|
||||
}
|
||||
|
||||
return best.sort((a, b) => a.distanceKm - b.distanceKm);
|
||||
}
|
||||
+85
-10
@@ -14,6 +14,7 @@ import { Unit } from '@openmeteo/sdk/unit';
|
||||
import { fetchWeatherApi } from 'openmeteo';
|
||||
|
||||
import { type DaylightBand, buildDaylightBands } from '$lib/charts/bands';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
|
||||
import type { VariablesWithTime } from '@openmeteo/sdk/variables-with-time';
|
||||
@@ -313,8 +314,8 @@ export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
|
||||
msg.includes('network request failed')
|
||||
) {
|
||||
return {
|
||||
title: "Couldn't reach the weather service",
|
||||
hint: 'Check your internet connection and try again.',
|
||||
title: m.err_network_title(),
|
||||
hint: m.err_network_hint(),
|
||||
detail: raw
|
||||
};
|
||||
}
|
||||
@@ -325,21 +326,21 @@ export function humanizeWeatherError(err: unknown): FriendlyWeatherError {
|
||||
msg.includes('coordinates')
|
||||
) {
|
||||
return {
|
||||
title: 'No data for this location with the selected model',
|
||||
hint: 'Regional weather models only cover their own area — "Best match" picks a suitable model automatically.',
|
||||
title: m.err_nodata_title(),
|
||||
hint: m.err_nodata_hint(),
|
||||
detail: raw
|
||||
};
|
||||
}
|
||||
if (msg.includes('invalid') || msg.includes('cannot be') || msg.includes('bad request')) {
|
||||
return {
|
||||
title: 'The weather service rejected the request',
|
||||
hint: 'Try different settings, or switch the model back to "Best match".',
|
||||
title: m.err_rejected_title(),
|
||||
hint: m.err_rejected_hint(),
|
||||
detail: raw
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'Loading the weather data failed',
|
||||
hint: 'Try again in a moment. If it keeps happening, switch the model to "Best match".',
|
||||
title: m.err_generic_title(),
|
||||
hint: m.err_generic_hint(),
|
||||
detail: raw
|
||||
};
|
||||
}
|
||||
@@ -936,6 +937,8 @@ export interface HistoricalForecastParams extends WeatherLocation, WeatherUnitPa
|
||||
end_date: string;
|
||||
/** Hourly API variables to request; defaults to the core week set. */
|
||||
hourlyVariables?: string[];
|
||||
/** Reanalysis to read from; omitted lets the API pick. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface HistoricalForecastResult {
|
||||
@@ -995,7 +998,8 @@ export async function fetchHistoricalWeather(
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
timezone: params.timezone,
|
||||
models: params.model && params.model !== 'best_match' ? params.model : undefined
|
||||
};
|
||||
|
||||
const cleanParams: Record<string, string> = {};
|
||||
@@ -1201,6 +1205,8 @@ export interface SeasonalForecastParams extends WeatherLocation, WeatherUnitPara
|
||||
dailyVariables?: string[];
|
||||
/** Lead time in days; the API allows at most 216. */
|
||||
forecast_days?: number;
|
||||
/** Seasonal model; omitted lets the API pick. */
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface SeasonalForecastResult {
|
||||
@@ -1273,7 +1279,8 @@ export async function fetchSeasonalForecast(
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
timezone: params.timezone
|
||||
timezone: params.timezone,
|
||||
models: params.model && params.model !== 'best_match' ? params.model : undefined
|
||||
};
|
||||
|
||||
const cleanParams: Record<string, string> = {};
|
||||
@@ -1372,3 +1379,71 @@ export async function fetchSeasonalForecast(
|
||||
timezone
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Nearby cities snapshot ─────────────────────────────────────────────────────
|
||||
|
||||
export interface NearbyDaily {
|
||||
/** local calendar date ("yyyy-MM-dd") -> that day's summary for this city */
|
||||
byDate: Record<string, { weatherCode: number; max: number; min: number; precipitation: number }>;
|
||||
}
|
||||
|
||||
export interface NearbySnapshotParams extends WeatherUnitParams {
|
||||
points: { latitude: number; longitude: number }[];
|
||||
past_days?: number;
|
||||
forecast_days?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a daily summary for several locations in one request - the forecast
|
||||
* API takes comma-separated coordinates and answers with one response per
|
||||
* point, in order.
|
||||
*
|
||||
* Deliberately runs on best_match: the nearby list can reach well past the
|
||||
* domain of whatever regional model the page is showing, and a row of dashes
|
||||
* is worse than a row from a model that covers everywhere.
|
||||
*/
|
||||
export async function fetchNearbyDaily(
|
||||
params: NearbySnapshotParams
|
||||
): Promise<(NearbyDaily | null)[]> {
|
||||
if (params.points.length === 0) return [];
|
||||
|
||||
const apiParams: Record<string, string> = {
|
||||
latitude: params.points.map((p) => p.latitude).join(','),
|
||||
longitude: params.points.map((p) => p.longitude).join(','),
|
||||
daily: 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum',
|
||||
temperature_unit: params.temperature_unit ?? 'celsius',
|
||||
wind_speed_unit: params.wind_speed_unit ?? 'kmh',
|
||||
precipitation_unit: params.precipitation_unit ?? 'mm',
|
||||
past_days: String(params.past_days ?? 3),
|
||||
forecast_days: String(params.forecast_days ?? 16),
|
||||
timezone: 'auto'
|
||||
};
|
||||
|
||||
const responses = await fetchWeatherApi(FORECAST_URL, apiParams);
|
||||
|
||||
return params.points.map((_, i) => {
|
||||
const response = responses[i];
|
||||
const dailyBlock = response?.daily();
|
||||
if (!dailyBlock) return null;
|
||||
|
||||
const utcOffsetSeconds = response.utcOffsetSeconds();
|
||||
const codes = getValues(dailyBlock.variables(0)!);
|
||||
const max = getValues(dailyBlock.variables(1)!);
|
||||
const min = getValues(dailyBlock.variables(2)!);
|
||||
const precip = getValues(dailyBlock.variables(3)!);
|
||||
|
||||
// Same convention as the seasonal fetch: shift by the response's own
|
||||
// offset, then read the calendar date off the ISO string.
|
||||
const byDate: NearbyDaily['byDate'] = {};
|
||||
getTimestamps(dailyBlock).forEach((t, d) => {
|
||||
const key = new Date(t + utcOffsetSeconds * 1000).toISOString().slice(0, 10);
|
||||
byDate[key] = {
|
||||
weatherCode: codes[d],
|
||||
max: max[d],
|
||||
min: min[d],
|
||||
precipitation: precip[d]
|
||||
};
|
||||
});
|
||||
return { byDate };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -150,6 +150,10 @@ export const storedChartRange = persisted<ChartRangePref>('chart_range_v1', 'aut
|
||||
/** Selected ensemble model for the 14-day spread forecast. */
|
||||
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||
|
||||
/** Reanalysis used on the historical page, and the seasonal model. */
|
||||
export const storedArchiveModel = persisted<string>('archive_model', 'best_match');
|
||||
export const storedSeasonalModel = persisted<string>('seasonal_model', 'best_match');
|
||||
|
||||
/** Measurement units, shared across every forecast page and persisted. */
|
||||
export interface UnitPrefs {
|
||||
temperature_unit: 'celsius' | 'fahrenheit';
|
||||
|
||||
@@ -13,7 +13,14 @@ export const geoLocationNameToRoute = (name: string) => {
|
||||
// selections navigate here directly, no geocoding id involved
|
||||
const COORD_ROUTE = /^(-?\d+(?:\.\d+)?)N(-?\d+(?:\.\d+)?)E$/i;
|
||||
|
||||
export function buildLocationRoute(location: GeoLocation): string {
|
||||
/** Only the fields the route is built from, so callers holding a partial record
|
||||
* (the nearby-cities list, for one) don't have to fake a whole GeoLocation. */
|
||||
export type RoutableLocation = Pick<
|
||||
GeoLocation,
|
||||
'id' | 'name' | 'latitude' | 'longitude' | 'feature_code' | 'population'
|
||||
>;
|
||||
|
||||
export function buildLocationRoute(location: RoutableLocation): string {
|
||||
// coordinate-only locations (GPS) have no real geocoding id
|
||||
if (location.feature_code === 'COORD' || !location.id) {
|
||||
return `${location.latitude.toFixed(4)}N${location.longitude.toFixed(4)}E`;
|
||||
|
||||
@@ -1,33 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||
|
||||
import de from './content/de.svelte';
|
||||
import en from './content/en.svelte';
|
||||
import es from './content/es.svelte';
|
||||
import fr from './content/fr.svelte';
|
||||
import it from './content/it.svelte';
|
||||
</script>
|
||||
|
||||
<!-- TODO(vincent): replace the [bracketed] placeholders with your real postal
|
||||
address and country before deploying. An imprint without a serviceable
|
||||
address does not satisfy the imprint/contact requirements. -->
|
||||
<ProsePage title="Imprint" subtitle="Legal notice / provider identification.">
|
||||
<h2>Service provider</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li is operated as a personal, independent project.</p>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<p>
|
||||
Weather data shown on this site is provided for general information only. Forecasts are
|
||||
inherently uncertain; do not rely on them where life, health or property is at stake - consult
|
||||
official warnings from your national weather service instead.
|
||||
</p>
|
||||
<p>
|
||||
Weather and geodata are retrieved from
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which is not
|
||||
affiliated with this site. External links are provided in good faith; their content is the
|
||||
responsibility of the respective operators.
|
||||
</p>
|
||||
</ProsePage>
|
||||
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Impressum" subtitle="Anbieterkennzeichnung.">
|
||||
<h2>Diensteanbieter</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
E-Mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li wird als persönliches, unabhängiges Projekt betrieben.</p>
|
||||
|
||||
<h2>Haftungsausschluss</h2>
|
||||
<p>
|
||||
Die hier gezeigten Wetterdaten dienen ausschliesslich der allgemeinen Information. Vorhersagen
|
||||
sind naturgemäss unsicher; verlassen Sie sich nicht auf sie, wenn Leben, Gesundheit oder
|
||||
Sachwerte auf dem Spiel stehen - massgeblich sind die amtlichen Warnungen Ihres nationalen
|
||||
Wetterdienstes.
|
||||
</p>
|
||||
<p>
|
||||
Wetter- und Geodaten stammen von
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, das nicht mit
|
||||
dieser Seite verbunden ist. Externe Links werden nach bestem Wissen gesetzt; für deren Inhalte
|
||||
sind die jeweiligen Betreiber verantwortlich.
|
||||
</p>
|
||||
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieses Impressums.</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Imprint" subtitle="Legal notice / provider identification.">
|
||||
<h2>Service provider</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
Email: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li is operated as a personal, independent project.</p>
|
||||
|
||||
<h2>Disclaimer</h2>
|
||||
<p>
|
||||
Weather data shown on this site is provided for general information only. Forecasts are
|
||||
inherently uncertain; do not rely on them where life, health or property is at stake - consult
|
||||
official warnings from your national weather service instead.
|
||||
</p>
|
||||
<p>
|
||||
Weather and geodata are retrieved from
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, which is not
|
||||
affiliated with this site. External links are provided in good faith; their content is the
|
||||
responsibility of the respective operators.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Aviso legal" subtitle="Identificación del prestador del servicio.">
|
||||
<h2>Prestador del servicio</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
Correo electrónico: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li se gestiona como un proyecto personal e independiente.</p>
|
||||
|
||||
<h2>Descargo de responsabilidad</h2>
|
||||
<p>
|
||||
Los datos meteorológicos de este sitio son solo informativos. Los pronósticos son
|
||||
intrínsecamente inciertos; no confíes en ellos cuando estén en juego la vida, la salud o los
|
||||
bienes: consulta los avisos oficiales de tu servicio meteorológico nacional.
|
||||
</p>
|
||||
<p>
|
||||
Los datos meteorológicos y geográficos proceden de
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, que no está
|
||||
afiliado a este sitio. Los enlaces externos se ofrecen de buena fe; su contenido es
|
||||
responsabilidad de sus respectivos operadores.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
En caso de discrepancia prevalece la versión inglesa de este aviso legal.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Mentions légales" subtitle="Identification de l'éditeur.">
|
||||
<h2>Éditeur du service</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li est exploité comme un projet personnel et indépendant.</p>
|
||||
|
||||
<h2>Avertissement</h2>
|
||||
<p>
|
||||
Les données météo présentées ici sont fournies à titre d'information générale. Les prévisions
|
||||
sont par nature incertaines ; ne vous y fiez pas lorsque des vies, la santé ou des biens sont en
|
||||
jeu - consultez les alertes officielles de votre service météorologique national.
|
||||
</p>
|
||||
<p>
|
||||
Les données météo et géographiques proviennent d'
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, qui n'est pas
|
||||
affilié à ce site. Les liens externes sont fournis de bonne foi ; leur contenu relève de la
|
||||
responsabilité de leurs exploitants.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
En cas de divergence, la version anglaise de ces mentions fait foi.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Note legali" subtitle="Identificazione del fornitore del servizio.">
|
||||
<h2>Fornitore del servizio</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2<br />
|
||||
CH-6442 Gersau<br />
|
||||
Switzerland
|
||||
</address>
|
||||
<p>
|
||||
E-mail: <a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</p>
|
||||
<p>Drizz.li è gestito come progetto personale e indipendente.</p>
|
||||
|
||||
<h2>Avvertenza</h2>
|
||||
<p>
|
||||
I dati meteo mostrati su questo sito hanno finalità puramente informative. Le previsioni sono
|
||||
per natura incerte: non farvi affidamento quando sono in gioco la vita, la salute o i beni -
|
||||
consulta gli avvisi ufficiali del tuo servizio meteorologico nazionale.
|
||||
</p>
|
||||
<p>
|
||||
I dati meteo e geografici provengono da
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>, che non è
|
||||
affiliato a questo sito. I link esterni sono forniti in buona fede; i contenuti sono
|
||||
responsabilità dei rispettivi gestori.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
In caso di discrepanza prevale la versione inglese di queste note legali.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -1,147 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||
|
||||
import de from './content/de.svelte';
|
||||
import en from './content/en.svelte';
|
||||
import es from './content/es.svelte';
|
||||
import fr from './content/fr.svelte';
|
||||
import it from './content/it.svelte';
|
||||
</script>
|
||||
|
||||
<!-- TODO(vincent): fill in the [bracketed] placeholders (address, hosting
|
||||
provider, supervisory authority) before deploying. -->
|
||||
<ProsePage title="Privacy policy" subtitle="Last updated: 1 August 2026">
|
||||
<p>
|
||||
Drizz.li is built to need as little of your data as possible: there are no user accounts, no
|
||||
cookies, no advertising and no analytics or tracking scripts. This page explains what little
|
||||
processing does happen - when you browse the site, and when you support the project with a
|
||||
contribution.
|
||||
</p>
|
||||
|
||||
<h2>1. Controller</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
Email:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
|
||||
signup page).
|
||||
</p>
|
||||
|
||||
<h2>2. Browsing the site</h2>
|
||||
<h3>Weather data requests</h3>
|
||||
<p>
|
||||
Drizz.li is a static site: when you open a forecast, your browser fetches the weather data
|
||||
directly from the open-data APIs of
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>
|
||||
(api.open-meteo.com, geocoding-api.open-meteo.com, archive-api.open-meteo.com, ensemble-api.open-meteo.com,
|
||||
and map tiles from maps.open-meteo.com / map-tiles.open-meteo.com / map-assets.open-meteo.com). Like
|
||||
any web request, this transmits your IP address and the requested location or search term to Open-Meteo.
|
||||
We do not receive or store any of this. See
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>Open-Meteo's terms and privacy information</a
|
||||
>. Legal basis: our legitimate interest in delivering the content you request (Art. 6(1)(f)
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Hosting</h3>
|
||||
<p>
|
||||
The static site is served by [hosting provider, location]. The hosting infrastructure may keep
|
||||
short-lived technical server logs (IP address, requested URL, timestamp) for security and
|
||||
operations. Legal basis: legitimate interest in a secure, reliable service (Art. 6(1)(f) GDPR).
|
||||
</p>
|
||||
<h3>Settings on your device (local storage)</h3>
|
||||
<p>
|
||||
Your preferences - theme, measurement units, last searched location, and (for supporters) your
|
||||
access key and its last verification result - are stored only in your browser's local storage.
|
||||
They never leave your device except as described below for key verification, and you can clear
|
||||
them at any time via your browser.
|
||||
</p>
|
||||
|
||||
<h2>3. Supporting the project (contributions)</h2>
|
||||
<p>
|
||||
Supporter contributions unlock the supporter extras, so legally they are a paid agreement, not a
|
||||
pure gift - and this is what we process to handle them:
|
||||
</p>
|
||||
<h3>Signup form</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Email address</strong> (required) - to send you the transfer details and, after your contribution
|
||||
arrives, your access key.
|
||||
</li>
|
||||
<li><strong>Name</strong> (optional) - to help match your bank transfer.</li>
|
||||
<li>
|
||||
<strong>Currency and amount</strong> - shown based on your browser's locale/timezone, detected on
|
||||
your device (no geolocation request is made).
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP address and technical anti-bot signals</strong> - kept with the signup request to prevent
|
||||
abuse and spam of the form.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment reference</strong> (e.g. DRZ-XXXXXX) - generated per request to match your transfer
|
||||
to your signup.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The transfer details (bank account and reference) are sent to you by email rather than shown on
|
||||
the page. Legal bases: performance of the agreement (Art. 6(1)(b) GDPR) and, for the anti-abuse
|
||||
measures, legitimate interest (Art. 6(1)(f) GDPR).
|
||||
</p>
|
||||
<h3>Bank transfer</h3>
|
||||
<p>
|
||||
Contributions are paid by ordinary bank transfer. Your bank and ours process the transfer data
|
||||
under their own responsibility; on our bank statement we see the usual transfer details (your
|
||||
name, account number, amount, reference). We use them only to match your contribution and are
|
||||
required to retain accounting records under statutory bookkeeping and tax law (Art. 6(1)(c)
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Access key and verification</h3>
|
||||
<p>
|
||||
After your transfer is matched (manually, usually within 24 hours), you receive an access key by
|
||||
email. When you paste it into Drizz.li, your browser sends the key to our verification endpoint
|
||||
(support.drizz.li) to check whether it is active; the response contains only the validity, tier
|
||||
and expiry. The subscriber list (key, email, expiry) is stored on our server for as long as your
|
||||
access is active.
|
||||
</p>
|
||||
<h3>Email</h3>
|
||||
<p>
|
||||
Transactional emails (transfer details, access key, renewals) are sent through our email
|
||||
provider, Strato (Strato AG, Germany), acting as a processor. We send no newsletters or
|
||||
marketing email.
|
||||
</p>
|
||||
|
||||
<h2>4. Retention</h2>
|
||||
<ul>
|
||||
<li>Signup requests that are never paid are deleted after 6 months at the latest.</li>
|
||||
<li>
|
||||
Supporter records (key, email, expiry) are kept while your access is active and deleted within
|
||||
12 months after it expires, unless you ask us to delete them sooner.
|
||||
</li>
|
||||
<li>
|
||||
Accounting records (bank statements showing your transfer) are kept for the statutory
|
||||
retention period (up to 10 years, depending on jurisdiction).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Recipients</h2>
|
||||
<p>
|
||||
We do not sell or share personal data with third parties for their own purposes. Recipients are
|
||||
limited to: Open-Meteo (weather requests made directly by your browser), our hosting provider,
|
||||
our email provider (Strato), and the banks involved in your transfer.
|
||||
</p>
|
||||
|
||||
<h2>6. Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you have the right to access, rectification, erasure, restriction of processing,
|
||||
data portability, and to object to processing based on legitimate interest. Where processing is
|
||||
based on consent, you may withdraw it at any time. To exercise any of these rights, email
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. You also have the
|
||||
right to lodge a complaint with a supervisory authority, in particular in the EU member state of
|
||||
your residence, or with [competent supervisory authority of the controller].
|
||||
</p>
|
||||
<p>
|
||||
For visitors from Switzerland: the corresponding rights under the Swiss Federal Act on Data
|
||||
Protection (FADP) apply equivalently.
|
||||
</p>
|
||||
|
||||
<h2>7. Changes</h2>
|
||||
<p>
|
||||
We may update this policy when the service changes; the date above reflects the latest revision.
|
||||
</p>
|
||||
</ProsePage>
|
||||
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Datenschutzerklärung" subtitle="Zuletzt aktualisiert: 1. August 2026">
|
||||
<p>
|
||||
Drizz.li ist so gebaut, dass möglichst wenige Ihrer Daten nötig sind: keine Benutzerkonten,
|
||||
keine Cookies, keine Werbung, keine Analyse- oder Tracking-Skripte. Diese Seite erklärt, welche
|
||||
wenige Verarbeitung dennoch stattfindet - beim Besuch der Seite und wenn Sie das Projekt mit
|
||||
einem Beitrag unterstützen.
|
||||
</p>
|
||||
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
E-Mail:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
Diese Erklärung gilt für die Websites drizz.li (die Wetter-App) und support.drizz.li (die
|
||||
Unterstützer-Anmeldung).
|
||||
</p>
|
||||
|
||||
<h2>2. Besuch der Website</h2>
|
||||
<h3>Abruf der Wetterdaten</h3>
|
||||
<p>
|
||||
Drizz.li ist eine statische Seite: Wenn Sie eine Vorhersage öffnen, ruft Ihr Browser die
|
||||
Wetterdaten direkt bei den Open-Data-Schnittstellen von
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Wie bei jeder
|
||||
Webanfrage werden dabei Ihre IP-Adresse und der gewünschte Ort bzw. Suchbegriff an Open-Meteo
|
||||
übertragen. Wir erhalten und speichern davon nichts. Siehe
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>Nutzungs- und Datenschutzhinweise von Open-Meteo</a
|
||||
>. Rechtsgrundlage: unser berechtigtes Interesse an der Auslieferung der angeforderten Inhalte
|
||||
(Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
<h3>Hosting</h3>
|
||||
<p>
|
||||
Die statische Seite wird von [Hosting-Anbieter, Standort] ausgeliefert. Die
|
||||
Hosting-Infrastruktur kann kurzlebige technische Server-Logs (IP-Adresse, angefragte URL,
|
||||
Zeitstempel) zu Sicherheits- und Betriebszwecken speichern. Rechtsgrundlage: berechtigtes
|
||||
Interesse an einem sicheren, zuverlässigen Dienst (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
<h3>Einstellungen auf Ihrem Gerät (Local Storage)</h3>
|
||||
<p>
|
||||
Ihre Einstellungen - Design, Masseinheiten, zuletzt gesuchter Ort und (für Unterstützer) Ihr
|
||||
Zugangsschlüssel samt letztem Prüfergebnis - werden ausschliesslich im Local Storage Ihres
|
||||
Browsers gespeichert. Sie verlassen Ihr Gerät nicht, ausser wie unten für die Schlüsselprüfung
|
||||
beschrieben, und Sie können sie jederzeit über Ihren Browser löschen.
|
||||
</p>
|
||||
|
||||
<h2>3. Das Projekt unterstützen (Beiträge)</h2>
|
||||
<p>
|
||||
Unterstützerbeiträge schalten die Extras frei, rechtlich sind sie daher eine entgeltliche
|
||||
Vereinbarung und keine reine Schenkung - das verarbeiten wir zur Abwicklung:
|
||||
</p>
|
||||
<h3>Anmeldeformular</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>E-Mail-Adresse</strong> (erforderlich) - um Ihnen die Überweisungsdaten und nach Eingang
|
||||
Ihres Beitrags den Zugangsschlüssel zu senden.
|
||||
</li>
|
||||
<li><strong>Name</strong> (optional) - zur leichteren Zuordnung Ihrer Überweisung.</li>
|
||||
<li>
|
||||
<strong>Währung und Betrag</strong> - anhand der Spracheinstellung/Zeitzone Ihres Browsers auf Ihrem
|
||||
Gerät ermittelt (keine Standortabfrage).
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP-Adresse und technische Anti-Bot-Signale</strong> - zusammen mit der Anfrage gespeichert,
|
||||
um Missbrauch und Spam zu verhindern.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Zahlungsreferenz</strong> (z. B. DRZ-XXXXXX) - je Anfrage erzeugt, um Ihre Überweisung Ihrer
|
||||
Anmeldung zuzuordnen.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Die Überweisungsdaten (Bankverbindung und Referenz) senden wir Ihnen per E-Mail statt sie auf
|
||||
der Seite anzuzeigen. Rechtsgrundlagen: Erfüllung der Vereinbarung (Art. 6 Abs. 1 lit. b DSGVO)
|
||||
und für die Missbrauchsabwehr berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
<h3>Banküberweisung</h3>
|
||||
<p>
|
||||
Beiträge werden per gewöhnlicher Banküberweisung gezahlt. Ihre und unsere Bank verarbeiten die
|
||||
Überweisungsdaten in eigener Verantwortung; auf unserem Kontoauszug sehen wir die üblichen
|
||||
Angaben (Name, Kontonummer, Betrag, Referenz). Wir nutzen sie nur zur Zuordnung Ihres Beitrags
|
||||
und sind nach handels- und steuerrechtlichen Vorschriften zur Aufbewahrung verpflichtet (Art. 6
|
||||
Abs. 1 lit. c DSGVO).
|
||||
</p>
|
||||
<h3>Zugangsschlüssel und Prüfung</h3>
|
||||
<p>
|
||||
Nach Zuordnung Ihrer Überweisung (manuell, meist innerhalb von 24 Stunden) erhalten Sie den
|
||||
Zugangsschlüssel per E-Mail. Wenn Sie ihn in Drizz.li einfügen, sendet Ihr Browser den Schlüssel
|
||||
an unseren Prüfendpunkt (support.drizz.li), um seine Gültigkeit zu prüfen; die Antwort enthält
|
||||
nur Gültigkeit, Stufe und Ablauf. Die Abonnentenliste (Schlüssel, E-Mail, Ablauf) liegt auf
|
||||
unserem Server, solange Ihr Zugang aktiv ist.
|
||||
</p>
|
||||
<h3>E-Mail</h3>
|
||||
<p>
|
||||
Transaktions-E-Mails (Überweisungsdaten, Zugangsschlüssel, Verlängerungen) versenden wir über
|
||||
unseren E-Mail-Anbieter Strato (Strato AG, Deutschland) als Auftragsverarbeiter. Newsletter oder
|
||||
Werbe-E-Mails versenden wir nicht.
|
||||
</p>
|
||||
|
||||
<h2>4. Speicherdauer</h2>
|
||||
<ul>
|
||||
<li>Nicht bezahlte Anmeldungen werden spätestens nach 6 Monaten gelöscht.</li>
|
||||
<li>
|
||||
Unterstützerdaten (Schlüssel, E-Mail, Ablauf) werden für die Dauer Ihres Zugangs aufbewahrt
|
||||
und innerhalb von 12 Monaten nach dessen Ablauf gelöscht, sofern Sie keine frühere Löschung
|
||||
wünschen.
|
||||
</li>
|
||||
<li>
|
||||
Buchhaltungsunterlagen (Kontoauszüge mit Ihrer Überweisung) werden für die gesetzliche
|
||||
Aufbewahrungsfrist gespeichert (je nach Rechtsordnung bis zu 10 Jahre).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Empfänger</h2>
|
||||
<p>
|
||||
Wir verkaufen personenbezogene Daten nicht und geben sie nicht für eigene Zwecke Dritter weiter.
|
||||
Empfänger sind ausschliesslich: Open-Meteo (Wetterabrufe direkt durch Ihren Browser), unser
|
||||
Hosting-Anbieter, unser E-Mail-Anbieter (Strato) und die an der Überweisung beteiligten Banken.
|
||||
</p>
|
||||
|
||||
<h2>6. Ihre Rechte</h2>
|
||||
<p>
|
||||
Nach der DSGVO haben Sie das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der
|
||||
Verarbeitung, Datenübertragbarkeit sowie das Recht, einer auf berechtigtem Interesse beruhenden
|
||||
Verarbeitung zu widersprechen. Beruht eine Verarbeitung auf Einwilligung, können Sie diese
|
||||
jederzeit widerrufen. Zur Ausübung schreiben Sie an
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Ausserdem haben
|
||||
Sie das Recht, sich bei einer Aufsichtsbehörde zu beschweren, insbesondere im EU-Mitgliedstaat
|
||||
Ihres Wohnsitzes, oder bei [zuständige Aufsichtsbehörde des Verantwortlichen].
|
||||
</p>
|
||||
<p>
|
||||
Für Besucherinnen und Besucher aus der Schweiz gelten die entsprechenden Rechte nach dem
|
||||
Bundesgesetz über den Datenschutz (DSG) sinngemäss.
|
||||
</p>
|
||||
|
||||
<h2>7. Änderungen</h2>
|
||||
<p>
|
||||
Wir aktualisieren diese Erklärung, wenn sich der Dienst ändert; das Datum oben nennt die letzte
|
||||
Fassung.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
Massgeblich ist die englische Fassung dieser Datenschutzerklärung.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Privacy policy" subtitle="Last updated: 1 August 2026">
|
||||
<p>
|
||||
Drizz.li is built to need as little of your data as possible: there are no user accounts, no
|
||||
cookies, no advertising and no analytics or tracking scripts. This page explains what little
|
||||
processing does happen - when you browse the site, and when you support the project with a
|
||||
contribution.
|
||||
</p>
|
||||
|
||||
<h2>1. Controller</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
Email:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
This policy covers the websites drizz.li (the weather app) and support.drizz.li (the supporter
|
||||
signup page).
|
||||
</p>
|
||||
|
||||
<h2>2. Browsing the site</h2>
|
||||
<h3>Weather data requests</h3>
|
||||
<p>
|
||||
Drizz.li is a static site: when you open a forecast, your browser fetches the weather data
|
||||
directly from the open-data APIs of
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>
|
||||
(api.open-meteo.com, geocoding-api.open-meteo.com, archive-api.open-meteo.com, ensemble-api.open-meteo.com,
|
||||
and map tiles from maps.open-meteo.com / map-tiles.open-meteo.com / map-assets.open-meteo.com). Like
|
||||
any web request, this transmits your IP address and the requested location or search term to Open-Meteo.
|
||||
We do not receive or store any of this. See
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>Open-Meteo's terms and privacy information</a
|
||||
>. Legal basis: our legitimate interest in delivering the content you request (Art. 6(1)(f)
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Hosting</h3>
|
||||
<p>
|
||||
The static site is served by [hosting provider, location]. The hosting infrastructure may keep
|
||||
short-lived technical server logs (IP address, requested URL, timestamp) for security and
|
||||
operations. Legal basis: legitimate interest in a secure, reliable service (Art. 6(1)(f) GDPR).
|
||||
</p>
|
||||
<h3>Settings on your device (local storage)</h3>
|
||||
<p>
|
||||
Your preferences - theme, measurement units, last searched location, and (for supporters) your
|
||||
access key and its last verification result - are stored only in your browser's local storage.
|
||||
They never leave your device except as described below for key verification, and you can clear
|
||||
them at any time via your browser.
|
||||
</p>
|
||||
|
||||
<h2>3. Supporting the project (contributions)</h2>
|
||||
<p>
|
||||
Supporter contributions unlock the supporter extras, so legally they are a paid agreement, not a
|
||||
pure gift - and this is what we process to handle them:
|
||||
</p>
|
||||
<h3>Signup form</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Email address</strong> (required) - to send you the transfer details and, after your contribution
|
||||
arrives, your access key.
|
||||
</li>
|
||||
<li><strong>Name</strong> (optional) - to help match your bank transfer.</li>
|
||||
<li>
|
||||
<strong>Currency and amount</strong> - shown based on your browser's locale/timezone, detected on
|
||||
your device (no geolocation request is made).
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP address and technical anti-bot signals</strong> - kept with the signup request to prevent
|
||||
abuse and spam of the form.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Payment reference</strong> (e.g. DRZ-XXXXXX) - generated per request to match your transfer
|
||||
to your signup.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
The transfer details (bank account and reference) are sent to you by email rather than shown on
|
||||
the page. Legal bases: performance of the agreement (Art. 6(1)(b) GDPR) and, for the anti-abuse
|
||||
measures, legitimate interest (Art. 6(1)(f) GDPR).
|
||||
</p>
|
||||
<h3>Bank transfer</h3>
|
||||
<p>
|
||||
Contributions are paid by ordinary bank transfer. Your bank and ours process the transfer data
|
||||
under their own responsibility; on our bank statement we see the usual transfer details (your
|
||||
name, account number, amount, reference). We use them only to match your contribution and are
|
||||
required to retain accounting records under statutory bookkeeping and tax law (Art. 6(1)(c)
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Access key and verification</h3>
|
||||
<p>
|
||||
After your transfer is matched (manually, usually within 24 hours), you receive an access key by
|
||||
email. When you paste it into Drizz.li, your browser sends the key to our verification endpoint
|
||||
(support.drizz.li) to check whether it is active; the response contains only the validity, tier
|
||||
and expiry. The subscriber list (key, email, expiry) is stored on our server for as long as your
|
||||
access is active.
|
||||
</p>
|
||||
<h3>Email</h3>
|
||||
<p>
|
||||
Transactional emails (transfer details, access key, renewals) are sent through our email
|
||||
provider, Strato (Strato AG, Germany), acting as a processor. We send no newsletters or
|
||||
marketing email.
|
||||
</p>
|
||||
|
||||
<h2>4. Retention</h2>
|
||||
<ul>
|
||||
<li>Signup requests that are never paid are deleted after 6 months at the latest.</li>
|
||||
<li>
|
||||
Supporter records (key, email, expiry) are kept while your access is active and deleted within
|
||||
12 months after it expires, unless you ask us to delete them sooner.
|
||||
</li>
|
||||
<li>
|
||||
Accounting records (bank statements showing your transfer) are kept for the statutory
|
||||
retention period (up to 10 years, depending on jurisdiction).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Recipients</h2>
|
||||
<p>
|
||||
We do not sell or share personal data with third parties for their own purposes. Recipients are
|
||||
limited to: Open-Meteo (weather requests made directly by your browser), our hosting provider,
|
||||
our email provider (Strato), and the banks involved in your transfer.
|
||||
</p>
|
||||
|
||||
<h2>6. Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you have the right to access, rectification, erasure, restriction of processing,
|
||||
data portability, and to object to processing based on legitimate interest. Where processing is
|
||||
based on consent, you may withdraw it at any time. To exercise any of these rights, email
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. You also have the
|
||||
right to lodge a complaint with a supervisory authority, in particular in the EU member state of
|
||||
your residence, or with [competent supervisory authority of the controller].
|
||||
</p>
|
||||
<p>
|
||||
For visitors from Switzerland: the corresponding rights under the Swiss Federal Act on Data
|
||||
Protection (FADP) apply equivalently.
|
||||
</p>
|
||||
|
||||
<h2>7. Changes</h2>
|
||||
<p>
|
||||
We may update this policy when the service changes; the date above reflects the latest revision.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Política de privacidad" subtitle="Última actualización: 1 de agosto de 2026">
|
||||
<p>
|
||||
Drizz.li está hecho para necesitar los mínimos datos posibles: sin cuentas, sin cookies, sin
|
||||
publicidad y sin scripts de analítica ni de seguimiento. Esta página explica el poco tratamiento
|
||||
que sí ocurre: al navegar por el sitio y al apoyar el proyecto con una aportación.
|
||||
</p>
|
||||
|
||||
<h2>1. Responsable</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
Correo electrónico:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
Esta política cubre los sitios drizz.li (la app meteorológica) y support.drizz.li (la página de
|
||||
registro de colaboradores).
|
||||
</p>
|
||||
|
||||
<h2>2. Navegación por el sitio</h2>
|
||||
<h3>Peticiones de datos meteorológicos</h3>
|
||||
<p>
|
||||
Drizz.li es un sitio estático: cuando abres un pronóstico, tu navegador obtiene los datos
|
||||
directamente de las API abiertas de
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Como en
|
||||
cualquier petición web, esto transmite tu dirección IP y la ubicación o término buscado a
|
||||
Open-Meteo. Nosotros no recibimos ni almacenamos nada de eso. Consulta
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>las condiciones e información de privacidad de Open-Meteo</a
|
||||
>. Base jurídica: nuestro interés legítimo en entregar el contenido que solicitas (art. 6.1.f
|
||||
del RGPD).
|
||||
</p>
|
||||
<h3>Alojamiento</h3>
|
||||
<p>
|
||||
El sitio estático lo sirve [proveedor de alojamiento, ubicación]. La infraestructura puede
|
||||
conservar registros técnicos de corta duración (dirección IP, URL solicitada, marca de tiempo)
|
||||
por seguridad y operación. Base jurídica: interés legítimo en un servicio seguro y fiable (art.
|
||||
6.1.f del RGPD).
|
||||
</p>
|
||||
<h3>Ajustes en tu dispositivo (almacenamiento local)</h3>
|
||||
<p>
|
||||
Tus preferencias - tema, unidades, última ubicación buscada y (para colaboradores) tu clave de
|
||||
acceso y su última verificación - se guardan únicamente en el almacenamiento local de tu
|
||||
navegador. No salen de tu dispositivo salvo como se describe abajo para verificar la clave, y
|
||||
puedes borrarlas cuando quieras.
|
||||
</p>
|
||||
|
||||
<h2>3. Apoyar el proyecto (aportaciones)</h2>
|
||||
<p>
|
||||
Las aportaciones desbloquean los extras, así que legalmente son un acuerdo remunerado y no una
|
||||
donación pura; esto es lo que tratamos para gestionarlas:
|
||||
</p>
|
||||
<h3>Formulario de registro</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Dirección de correo</strong> (obligatoria) - para enviarte los datos de la transferencia
|
||||
y, cuando llegue tu aportación, tu clave de acceso.
|
||||
</li>
|
||||
<li><strong>Nombre</strong> (opcional) - para ayudar a casar tu transferencia.</li>
|
||||
<li>
|
||||
<strong>Moneda e importe</strong> - determinados por la configuración regional/zona horaria de tu
|
||||
navegador, en tu dispositivo (no se solicita geolocalización).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Dirección IP y señales técnicas anti-bots</strong> - guardadas con la solicitud para evitar
|
||||
abusos y spam.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Referencia de pago</strong> (p. ej. DRZ-XXXXXX) - generada por solicitud para casar tu transferencia
|
||||
con tu registro.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Los datos de la transferencia (cuenta y referencia) se te envían por correo en lugar de
|
||||
mostrarse en la página. Bases jurídicas: ejecución del contrato (art. 6.1.b del RGPD) y, para
|
||||
las medidas antiabuso, interés legítimo (art. 6.1.f del RGPD).
|
||||
</p>
|
||||
<h3>Transferencia bancaria</h3>
|
||||
<p>
|
||||
Las aportaciones se pagan por transferencia bancaria ordinaria. Tu banco y el nuestro tratan los
|
||||
datos bajo su propia responsabilidad; en nuestro extracto vemos los datos habituales (nombre,
|
||||
número de cuenta, importe, referencia). Solo los usamos para casar tu aportación y debemos
|
||||
conservar los registros contables por obligación legal (art. 6.1.c del RGPD).
|
||||
</p>
|
||||
<h3>Clave de acceso y verificación</h3>
|
||||
<p>
|
||||
Una vez casada tu transferencia (manualmente, normalmente en 24 horas), recibes una clave de
|
||||
acceso por correo. Al pegarla en Drizz.li, tu navegador envía la clave a nuestro punto de
|
||||
verificación (support.drizz.li) para comprobar si está activa; la respuesta contiene solo
|
||||
validez, nivel y caducidad. La lista de suscriptores (clave, correo, caducidad) se guarda en
|
||||
nuestro servidor mientras tu acceso esté activo.
|
||||
</p>
|
||||
<h3>Correo electrónico</h3>
|
||||
<p>
|
||||
Los correos transaccionales (datos de transferencia, clave de acceso, renovaciones) se envían a
|
||||
través de nuestro proveedor Strato (Strato AG, Alemania), que actúa como encargado del
|
||||
tratamiento. No enviamos boletines ni correo comercial.
|
||||
</p>
|
||||
|
||||
<h2>4. Conservación</h2>
|
||||
<ul>
|
||||
<li>Las solicitudes de registro que nunca se pagan se eliminan como máximo a los 6 meses.</li>
|
||||
<li>
|
||||
Los registros de colaborador (clave, correo, caducidad) se conservan mientras tu acceso esté
|
||||
activo y se eliminan en los 12 meses siguientes a su expiración, salvo que pidas borrarlos
|
||||
antes.
|
||||
</li>
|
||||
<li>
|
||||
Los registros contables (extractos que muestran tu transferencia) se conservan durante el
|
||||
plazo legal (hasta 10 años según la jurisdicción).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Destinatarios</h2>
|
||||
<p>
|
||||
No vendemos ni compartimos datos personales con terceros para sus propios fines. Los
|
||||
destinatarios se limitan a: Open-Meteo (peticiones meteorológicas hechas directamente por tu
|
||||
navegador), nuestro proveedor de alojamiento, nuestro proveedor de correo (Strato) y los bancos
|
||||
implicados en tu transferencia.
|
||||
</p>
|
||||
|
||||
<h2>6. Tus derechos</h2>
|
||||
<p>
|
||||
Conforme al RGPD tienes derecho de acceso, rectificación, supresión, limitación del tratamiento,
|
||||
portabilidad y oposición al tratamiento basado en el interés legítimo. Cuando el tratamiento se
|
||||
base en el consentimiento, puedes retirarlo en cualquier momento. Para ejercer estos derechos,
|
||||
escribe a
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. También tienes
|
||||
derecho a presentar una reclamación ante una autoridad de control, en particular en el Estado
|
||||
miembro de tu residencia, o ante [autoridad de control competente].
|
||||
</p>
|
||||
<p>
|
||||
Para visitantes desde Suiza, se aplican de forma equivalente los derechos correspondientes de la
|
||||
Ley Federal de Protección de Datos (LPD).
|
||||
</p>
|
||||
|
||||
<h2>7. Cambios</h2>
|
||||
<p>
|
||||
Podemos actualizar esta política cuando cambie el servicio; la fecha de arriba refleja la última
|
||||
revisión.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
En caso de discrepancia prevalece la versión inglesa de esta política.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Politique de confidentialité" subtitle="Dernière mise à jour : 1er août 2026">
|
||||
<p>
|
||||
Drizz.li est conçu pour avoir besoin du minimum de vos données : aucun compte, aucun cookie,
|
||||
aucune publicité, aucun script d'analyse ou de suivi. Cette page explique le peu de traitement
|
||||
qui a lieu - lorsque vous consultez le site et lorsque vous soutenez le projet par une
|
||||
contribution.
|
||||
</p>
|
||||
|
||||
<h2>1. Responsable du traitement</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
E-mail:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
Cette politique couvre les sites drizz.li (l'application météo) et support.drizz.li (la page
|
||||
d'inscription contributeur).
|
||||
</p>
|
||||
|
||||
<h2>2. Navigation sur le site</h2>
|
||||
<h3>Requêtes de données météo</h3>
|
||||
<p>
|
||||
Drizz.li est un site statique : lorsque vous ouvrez une prévision, votre navigateur récupère les
|
||||
données directement auprès des API ouvertes d'
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Comme pour toute
|
||||
requête web, cela transmet votre adresse IP et le lieu ou le terme recherché à Open-Meteo. Nous
|
||||
n'en recevons ni n'en conservons rien. Voir
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>les conditions et informations de confidentialité d'Open-Meteo</a
|
||||
>. Base légale : notre intérêt légitime à fournir le contenu demandé (art. 6, par. 1, point f)
|
||||
du RGPD).
|
||||
</p>
|
||||
<h3>Hébergement</h3>
|
||||
<p>
|
||||
Le site statique est servi par [hébergeur, localisation]. L'infrastructure d'hébergement peut
|
||||
conserver de brefs journaux techniques (adresse IP, URL demandée, horodatage) à des fins de
|
||||
sécurité et d'exploitation. Base légale : intérêt légitime à un service sûr et fiable (art. 6,
|
||||
par. 1, point f) du RGPD).
|
||||
</p>
|
||||
<h3>Réglages sur votre appareil (stockage local)</h3>
|
||||
<p>
|
||||
Vos préférences - thème, unités, dernier lieu recherché et (pour les contributeurs) votre clé
|
||||
d'accès et son dernier résultat de vérification - sont stockées uniquement dans le stockage
|
||||
local de votre navigateur. Elles ne quittent pas votre appareil, sauf comme décrit ci-dessous
|
||||
pour la vérification de la clé, et vous pouvez les effacer à tout moment.
|
||||
</p>
|
||||
|
||||
<h2>3. Soutenir le projet (contributions)</h2>
|
||||
<p>
|
||||
Les contributions débloquent les bonus : juridiquement il s'agit donc d'un accord payant et non
|
||||
d'un simple don - voici ce que nous traitons pour les gérer :
|
||||
</p>
|
||||
<h3>Formulaire d'inscription</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Adresse e-mail</strong> (obligatoire) - pour vous envoyer les coordonnées bancaires puis,
|
||||
à réception de votre contribution, votre clé d'accès.
|
||||
</li>
|
||||
<li><strong>Nom</strong> (facultatif) - pour faciliter le rapprochement de votre virement.</li>
|
||||
<li>
|
||||
<strong>Devise et montant</strong> - déterminés d'après la langue/le fuseau horaire de votre navigateur,
|
||||
sur votre appareil (aucune demande de géolocalisation).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Adresse IP et signaux anti-robots techniques</strong> - conservés avec la demande pour prévenir
|
||||
les abus et le spam.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Référence de paiement</strong> (p. ex. DRZ-XXXXXX) - générée par demande pour rapprocher
|
||||
votre virement de votre inscription.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Les coordonnées bancaires (compte et référence) vous sont envoyées par e-mail plutôt
|
||||
qu'affichées sur la page. Bases légales : exécution du contrat (art. 6, par. 1, point b) du
|
||||
RGPD) et, pour les mesures anti-abus, intérêt légitime (art. 6, par. 1, point f) du RGPD).
|
||||
</p>
|
||||
<h3>Virement bancaire</h3>
|
||||
<p>
|
||||
Les contributions sont réglées par virement bancaire ordinaire. Votre banque et la nôtre
|
||||
traitent les données du virement sous leur propre responsabilité ; sur notre relevé figurent les
|
||||
informations habituelles (nom, numéro de compte, montant, référence). Nous ne les utilisons que
|
||||
pour rapprocher votre contribution et devons conserver les pièces comptables au titre des
|
||||
obligations légales (art. 6, par. 1, point c) du RGPD).
|
||||
</p>
|
||||
<h3>Clé d'accès et vérification</h3>
|
||||
<p>
|
||||
Une fois votre virement rapproché (manuellement, en général sous 24 heures), vous recevez une
|
||||
clé d'accès par e-mail. Lorsque vous la collez dans Drizz.li, votre navigateur envoie la clé à
|
||||
notre point de vérification (support.drizz.li) pour savoir si elle est active ; la réponse ne
|
||||
contient que la validité, le niveau et l'échéance. La liste des abonnés (clé, e-mail, échéance)
|
||||
est conservée sur notre serveur tant que votre accès est actif.
|
||||
</p>
|
||||
<h3>E-mail</h3>
|
||||
<p>
|
||||
Les e-mails transactionnels (coordonnées bancaires, clé d'accès, renouvellements) sont envoyés
|
||||
via notre prestataire Strato (Strato AG, Allemagne), agissant comme sous-traitant. Nous
|
||||
n'envoyons ni newsletter ni e-mail marketing.
|
||||
</p>
|
||||
|
||||
<h2>4. Conservation</h2>
|
||||
<ul>
|
||||
<li>Les demandes d'inscription jamais payées sont supprimées au plus tard après 6 mois.</li>
|
||||
<li>
|
||||
Les données contributeur (clé, e-mail, échéance) sont conservées tant que votre accès est
|
||||
actif et supprimées dans les 12 mois suivant son expiration, sauf demande de suppression
|
||||
anticipée.
|
||||
</li>
|
||||
<li>
|
||||
Les pièces comptables (relevés bancaires mentionnant votre virement) sont conservées pendant
|
||||
la durée légale (jusqu'à 10 ans selon la juridiction).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Destinataires</h2>
|
||||
<p>
|
||||
Nous ne vendons ni ne partageons de données personnelles avec des tiers pour leurs propres
|
||||
finalités. Les destinataires se limitent à : Open-Meteo (requêtes météo effectuées directement
|
||||
par votre navigateur), notre hébergeur, notre prestataire e-mail (Strato) et les banques
|
||||
impliquées dans votre virement.
|
||||
</p>
|
||||
|
||||
<h2>6. Vos droits</h2>
|
||||
<p>
|
||||
En vertu du RGPD, vous disposez d'un droit d'accès, de rectification, d'effacement, de
|
||||
limitation du traitement, de portabilité, et d'opposition au traitement fondé sur l'intérêt
|
||||
légitime. Lorsque le traitement repose sur le consentement, vous pouvez le retirer à tout
|
||||
moment. Pour exercer ces droits, écrivez à
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Vous avez
|
||||
également le droit d'introduire une réclamation auprès d'une autorité de contrôle, notamment
|
||||
dans l'État membre de votre résidence, ou auprès de [autorité de contrôle compétente].
|
||||
</p>
|
||||
<p>
|
||||
Pour les visiteurs suisses, les droits correspondants de la loi fédérale sur la protection des
|
||||
données (LPD) s'appliquent de manière équivalente.
|
||||
</p>
|
||||
|
||||
<h2>7. Modifications</h2>
|
||||
<p>
|
||||
Nous pouvons mettre à jour cette politique lorsque le service évolue ; la date ci-dessus indique
|
||||
la dernière révision.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
En cas de divergence, la version anglaise de cette politique fait foi.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Informativa sulla privacy" subtitle="Ultimo aggiornamento: 1 agosto 2026">
|
||||
<p>
|
||||
Drizz.li è costruito per aver bisogno del minimo dei tuoi dati: nessun account, nessun cookie,
|
||||
nessuna pubblicità, nessuno script di analisi o tracciamento. Questa pagina spiega il poco
|
||||
trattamento che avviene comunque: quando navighi sul sito e quando sostieni il progetto con un
|
||||
contributo.
|
||||
</p>
|
||||
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<address>
|
||||
Vincent van der Wal<br />
|
||||
Tschalungasse 2, CH-6442 Gersau, Switzerland <br />
|
||||
E-mail:
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
</address>
|
||||
<p>
|
||||
Questa informativa copre i siti drizz.li (l'app meteo) e support.drizz.li (la pagina di
|
||||
iscrizione per i sostenitori).
|
||||
</p>
|
||||
|
||||
<h2>2. Navigazione sul sito</h2>
|
||||
<h3>Richieste di dati meteo</h3>
|
||||
<p>
|
||||
Drizz.li è un sito statico: quando apri una previsione, il tuo browser scarica i dati
|
||||
direttamente dalle API aperte di
|
||||
<a href="https://open-meteo.com" target="_blank" rel="noopener">Open-Meteo</a>. Come per
|
||||
qualsiasi richiesta web, questo trasmette il tuo indirizzo IP e la località o il termine cercato
|
||||
a Open-Meteo. Noi non riceviamo né conserviamo nulla di tutto ciò. Vedi
|
||||
<a href="https://open-meteo.com/en/terms" target="_blank" rel="noopener"
|
||||
>le condizioni e le informazioni sulla privacy di Open-Meteo</a
|
||||
>. Base giuridica: il nostro legittimo interesse a fornire il contenuto richiesto (art. 6, par.
|
||||
1, lett. f del GDPR).
|
||||
</p>
|
||||
<h3>Hosting</h3>
|
||||
<p>
|
||||
Il sito statico è servito da [fornitore di hosting, sede]. L'infrastruttura può conservare brevi
|
||||
log tecnici del server (indirizzo IP, URL richiesto, data e ora) per sicurezza e gestione. Base
|
||||
giuridica: legittimo interesse a un servizio sicuro e affidabile (art. 6, par. 1, lett. f del
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Impostazioni sul tuo dispositivo (archiviazione locale)</h3>
|
||||
<p>
|
||||
Le tue preferenze - tema, unità di misura, ultima località cercata e (per i sostenitori) la
|
||||
chiave di accesso e l'ultimo esito della verifica - sono salvate solo nell'archiviazione locale
|
||||
del browser. Non lasciano il tuo dispositivo, salvo quanto descritto sotto per la verifica della
|
||||
chiave, e puoi cancellarle quando vuoi.
|
||||
</p>
|
||||
|
||||
<h2>3. Sostenere il progetto (contributi)</h2>
|
||||
<p>
|
||||
I contributi sbloccano gli extra, quindi giuridicamente sono un accordo a pagamento e non una
|
||||
pura donazione: ecco cosa trattiamo per gestirli:
|
||||
</p>
|
||||
<h3>Modulo di iscrizione</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Indirizzo e-mail</strong> (obbligatorio) - per inviarti i dati del bonifico e, all'arrivo
|
||||
del contributo, la chiave di accesso.
|
||||
</li>
|
||||
<li><strong>Nome</strong> (facoltativo) - per facilitare l'abbinamento del bonifico.</li>
|
||||
<li>
|
||||
<strong>Valuta e importo</strong> - determinati dalle impostazioni locali/fuso orario del browser,
|
||||
sul tuo dispositivo (nessuna richiesta di geolocalizzazione).
|
||||
</li>
|
||||
<li>
|
||||
<strong>Indirizzo IP e segnali tecnici anti-bot</strong> - conservati con la richiesta per prevenire
|
||||
abusi e spam.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Riferimento di pagamento</strong> (es. DRZ-XXXXXX) - generato per ogni richiesta per abbinare
|
||||
il bonifico all'iscrizione.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
I dati del bonifico (conto e riferimento) ti vengono inviati via e-mail anziché mostrati sulla
|
||||
pagina. Basi giuridiche: esecuzione del contratto (art. 6, par. 1, lett. b del GDPR) e, per le
|
||||
misure anti-abuso, legittimo interesse (art. 6, par. 1, lett. f del GDPR).
|
||||
</p>
|
||||
<h3>Bonifico bancario</h3>
|
||||
<p>
|
||||
I contributi si pagano con un normale bonifico. La tua banca e la nostra trattano i dati del
|
||||
bonifico sotto la propria responsabilità; sul nostro estratto conto vediamo i dati consueti
|
||||
(nome, numero di conto, importo, riferimento). Li usiamo solo per abbinare il contributo e siamo
|
||||
tenuti a conservare le scritture contabili per obbligo di legge (art. 6, par. 1, lett. c del
|
||||
GDPR).
|
||||
</p>
|
||||
<h3>Chiave di accesso e verifica</h3>
|
||||
<p>
|
||||
Dopo l'abbinamento del bonifico (manuale, di solito entro 24 ore) ricevi una chiave di accesso
|
||||
via e-mail. Quando la incolli in Drizz.li, il browser invia la chiave al nostro endpoint di
|
||||
verifica (support.drizz.li) per controllare se è attiva; la risposta contiene solo validità,
|
||||
livello e scadenza. L'elenco dei sostenitori (chiave, e-mail, scadenza) resta sul nostro server
|
||||
finché il tuo accesso è attivo.
|
||||
</p>
|
||||
<h3>E-mail</h3>
|
||||
<p>
|
||||
Le e-mail transazionali (dati del bonifico, chiave di accesso, rinnovi) sono inviate tramite il
|
||||
nostro fornitore Strato (Strato AG, Germania), che agisce come responsabile del trattamento. Non
|
||||
inviamo newsletter né e-mail di marketing.
|
||||
</p>
|
||||
|
||||
<h2>4. Conservazione</h2>
|
||||
<ul>
|
||||
<li>Le richieste di iscrizione mai pagate sono cancellate al più tardi dopo 6 mesi.</li>
|
||||
<li>
|
||||
I dati dei sostenitori (chiave, e-mail, scadenza) sono conservati finché l'accesso è attivo e
|
||||
cancellati entro 12 mesi dalla scadenza, salvo richiesta di cancellazione anticipata.
|
||||
</li>
|
||||
<li>
|
||||
Le scritture contabili (estratti conto con il tuo bonifico) sono conservate per il periodo di
|
||||
legge (fino a 10 anni a seconda della giurisdizione).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Destinatari</h2>
|
||||
<p>
|
||||
Non vendiamo né condividiamo dati personali con terzi per finalità proprie. I destinatari si
|
||||
limitano a: Open-Meteo (richieste meteo fatte direttamente dal tuo browser), il nostro fornitore
|
||||
di hosting, il nostro fornitore e-mail (Strato) e le banche coinvolte nel bonifico.
|
||||
</p>
|
||||
|
||||
<h2>6. I tuoi diritti</h2>
|
||||
<p>
|
||||
In base al GDPR hai diritto di accesso, rettifica, cancellazione, limitazione del trattamento,
|
||||
portabilità dei dati e opposizione al trattamento fondato sul legittimo interesse. Se il
|
||||
trattamento si basa sul consenso, puoi revocarlo in qualsiasi momento. Per esercitare questi
|
||||
diritti scrivi a
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>. Hai inoltre il
|
||||
diritto di proporre reclamo a un'autorità di controllo, in particolare nello Stato membro di
|
||||
residenza, o a [autorità di controllo competente].
|
||||
</p>
|
||||
<p>
|
||||
Per i visitatori dalla Svizzera valgono in modo equivalente i corrispondenti diritti previsti
|
||||
dalla Legge federale sulla protezione dei dati (LPD).
|
||||
</p>
|
||||
|
||||
<h2>7. Modifiche</h2>
|
||||
<p>
|
||||
Possiamo aggiornare questa informativa quando il servizio cambia; la data in alto indica
|
||||
l'ultima revisione.
|
||||
</p>
|
||||
<p class="text-muted-foreground">
|
||||
In caso di discrepanza prevale la versione inglese di questa informativa.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -1,82 +1,11 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
import LocalizedContent from '$lib/components/localized-content.svelte';
|
||||
|
||||
import de from './content/de.svelte';
|
||||
import en from './content/en.svelte';
|
||||
import es from './content/es.svelte';
|
||||
import fr from './content/fr.svelte';
|
||||
import it from './content/it.svelte';
|
||||
</script>
|
||||
|
||||
<!-- TODO(vincent): confirm the governing-law country in section 8. -->
|
||||
<ProsePage title="Supporter terms" subtitle="Last updated: 1 August 2026">
|
||||
<p>
|
||||
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
|
||||
that helps keep the project running and, as a thank-you, unlocks the supporter extras. Although
|
||||
we call it a contribution, features are unlocked in return.
|
||||
</p>
|
||||
|
||||
<h2>1. What you get</h2>
|
||||
<p>
|
||||
A supporter contribution unlocks the supporter extras for the paid period: currently historical
|
||||
weather with climate-normal comparisons, plus new supporter features as they land. The free
|
||||
parts of Drizz.li stay free for everyone.
|
||||
</p>
|
||||
|
||||
<h2>2. How it works</h2>
|
||||
<ul>
|
||||
<li>
|
||||
You request the transfer details on the signup page; we email them to you together with a
|
||||
personal payment reference.
|
||||
</li>
|
||||
<li>
|
||||
You transfer the amount (from €3 / $3 / CHF 3 per month) by ordinary bank transfer, including
|
||||
the reference.
|
||||
</li>
|
||||
<li>
|
||||
Transfers are matched manually. Once your contribution arrives - usually within 24 hours, at
|
||||
most a few days - you receive a personal access key by email.
|
||||
</li>
|
||||
<li>
|
||||
You paste the key into Drizz.li once; it is stored on your device and verified automatically.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. No auto-renewal</h2>
|
||||
<p>
|
||||
Contributions are one-off and prepaid: nothing renews automatically and we never charge you.
|
||||
When your period ends, the extras simply lock again; contributing again with the same email
|
||||
extends your existing key.
|
||||
</p>
|
||||
|
||||
<h2>4. Withdrawal and refunds</h2>
|
||||
<p>
|
||||
If you are a consumer in the EU/EEA, you have a statutory 14-day right of withdrawal. Beyond
|
||||
that, we keep it simple: if you are unhappy for any reason within 14 days of receiving your
|
||||
access key, email
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a> and we will refund your
|
||||
contribution in full.
|
||||
</p>
|
||||
|
||||
<h2>5. Fair use</h2>
|
||||
<p>
|
||||
The access key is personal. Please don't publish or share it; keys that are clearly abused (e.g.
|
||||
shared publicly) may be revoked. If your key is revoked without cause, you are entitled to a
|
||||
pro-rata refund.
|
||||
</p>
|
||||
|
||||
<h2>6. Service and availability</h2>
|
||||
<p>
|
||||
Drizz.li is a personal open-source project, provided as-is. We work to keep it available and
|
||||
accurate, but we cannot guarantee uninterrupted availability or the correctness of forecasts -
|
||||
weather data is informational only (see the disclaimer in the imprint). If the supporter extras
|
||||
become permanently unavailable during a period you paid for, we will refund the remaining period
|
||||
on request.
|
||||
</p>
|
||||
|
||||
<h2>7. Data</h2>
|
||||
<p>
|
||||
How we handle your data (email, payment reference, bank transfer details) is described in the
|
||||
<a href="/legal/privacy/">privacy policy</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Governing law</h2>
|
||||
<p>
|
||||
These terms are governed by the law of [Switzerland], without prejudice to mandatory consumer
|
||||
protection provisions of your country of residence.
|
||||
</p>
|
||||
</ProsePage>
|
||||
<LocalizedContent variants={{ en, de, es, fr, it }} />
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Unterstützerbedingungen" subtitle="Zuletzt aktualisiert: 1. August 2026">
|
||||
<p>
|
||||
Drizz.li ist kostenlos nutzbar. Diese Bedingungen betreffen den freiwilligen
|
||||
Unterstützerbeitrag: eine kleine Zahlung, die das Projekt am Laufen hält und als Dankeschön die
|
||||
Unterstützer-Extras freischaltet. Auch wenn wir von einem Beitrag sprechen, werden dafür
|
||||
Funktionen freigeschaltet.
|
||||
</p>
|
||||
|
||||
<h2>1. Was Sie erhalten</h2>
|
||||
<p>
|
||||
Ein Unterstützerbeitrag schaltet die Extras für den bezahlten Zeitraum frei: derzeit
|
||||
historisches Wetter mit Vergleich zu den Klimanormalen sowie neue Funktionen, sobald sie
|
||||
erscheinen. Die kostenlosen Teile von Drizz.li bleiben für alle frei.
|
||||
</p>
|
||||
|
||||
<h2>2. Ablauf</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Sie fordern die Überweisungsdaten auf der Anmeldeseite an; wir senden sie Ihnen zusammen mit
|
||||
einer persönlichen Zahlungsreferenz per E-Mail.
|
||||
</li>
|
||||
<li>
|
||||
Sie überweisen den Betrag (ab 3 € / 3 $ / 3 CHF pro Monat) per gewöhnlicher Banküberweisung
|
||||
unter Angabe der Referenz.
|
||||
</li>
|
||||
<li>
|
||||
Überweisungen werden manuell zugeordnet. Sobald Ihr Beitrag eingeht - meist innerhalb von 24
|
||||
Stunden, höchstens einige Tage - erhalten Sie per E-Mail einen persönlichen Zugangsschlüssel.
|
||||
</li>
|
||||
<li>
|
||||
Sie fügen den Schlüssel einmal in Drizz.li ein; er wird auf Ihrem Gerät gespeichert und
|
||||
automatisch überprüft.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Keine automatische Verlängerung</h2>
|
||||
<p>
|
||||
Beiträge sind einmalig und im Voraus bezahlt: Nichts verlängert sich automatisch und wir
|
||||
belasten Sie nie. Läuft Ihr Zeitraum ab, sperren sich die Extras einfach wieder; ein erneuter
|
||||
Beitrag mit derselben E-Mail verlängert Ihren bestehenden Schlüssel.
|
||||
</p>
|
||||
|
||||
<h2>4. Widerruf und Erstattung</h2>
|
||||
<p>
|
||||
Als Verbraucherin oder Verbraucher in der EU/im EWR haben Sie ein gesetzliches 14-tägiges
|
||||
Widerrufsrecht. Darüber hinaus halten wir es einfach: Sind Sie innerhalb von 14 Tagen nach
|
||||
Erhalt Ihres Zugangsschlüssels aus irgendeinem Grund unzufrieden, schreiben Sie an
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
und wir erstatten Ihren Beitrag vollständig.
|
||||
</p>
|
||||
|
||||
<h2>5. Faire Nutzung</h2>
|
||||
<p>
|
||||
Der Zugangsschlüssel ist persönlich. Bitte veröffentlichen oder teilen Sie ihn nicht; eindeutig
|
||||
missbrauchte Schlüssel (z. B. öffentlich geteilt) können gesperrt werden. Wird Ihr Schlüssel
|
||||
ohne Grund gesperrt, haben Sie Anspruch auf eine anteilige Erstattung.
|
||||
</p>
|
||||
|
||||
<h2>6. Dienst und Verfügbarkeit</h2>
|
||||
<p>
|
||||
Drizz.li ist ein persönliches Open-Source-Projekt und wird ohne Gewähr bereitgestellt. Wir
|
||||
bemühen uns um Verfügbarkeit und Genauigkeit, können aber weder unterbrechungsfreien Betrieb
|
||||
noch die Richtigkeit der Vorhersagen garantieren - Wetterdaten sind rein informativ (siehe
|
||||
Haftungsausschluss im Impressum). Sollten die Unterstützer-Extras während eines bezahlten
|
||||
Zeitraums dauerhaft ausfallen, erstatten wir auf Anfrage den Restzeitraum.
|
||||
</p>
|
||||
|
||||
<h2>7. Daten</h2>
|
||||
<p>
|
||||
Wie wir Ihre Daten (E-Mail, Zahlungsreferenz, Überweisungsdaten) verarbeiten, steht in der
|
||||
<a href={href('/legal/privacy')}>Datenschutzerklärung</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Anwendbares Recht</h2>
|
||||
<p>
|
||||
Für diese Bedingungen gilt das Recht der [Schweiz], unbeschadet zwingender
|
||||
Verbraucherschutzvorschriften Ihres Wohnsitzlandes.
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground">Massgeblich ist die englische Fassung dieser Bedingungen.</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Supporter terms" subtitle="Last updated: 1 August 2026">
|
||||
<p>
|
||||
Drizz.li is free to use. These terms cover the optional supporter contribution: a small payment
|
||||
that helps keep the project running and, as a thank-you, unlocks the supporter extras. Although
|
||||
we call it a contribution, features are unlocked in return.
|
||||
</p>
|
||||
|
||||
<h2>1. What you get</h2>
|
||||
<p>
|
||||
A supporter contribution unlocks the supporter extras for the paid period: currently historical
|
||||
weather with climate-normal comparisons, plus new supporter features as they land. The free
|
||||
parts of Drizz.li stay free for everyone.
|
||||
</p>
|
||||
|
||||
<h2>2. How it works</h2>
|
||||
<ul>
|
||||
<li>
|
||||
You request the transfer details on the signup page; we email them to you together with a
|
||||
personal payment reference.
|
||||
</li>
|
||||
<li>
|
||||
You transfer the amount (from €3 / $3 / CHF 3 per month) by ordinary bank transfer, including
|
||||
the reference.
|
||||
</li>
|
||||
<li>
|
||||
Transfers are matched manually. Once your contribution arrives - usually within 24 hours, at
|
||||
most a few days - you receive a personal access key by email.
|
||||
</li>
|
||||
<li>
|
||||
You paste the key into Drizz.li once; it is stored on your device and verified automatically.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. No auto-renewal</h2>
|
||||
<p>
|
||||
Contributions are one-off and prepaid: nothing renews automatically and we never charge you.
|
||||
When your period ends, the extras simply lock again; contributing again with the same email
|
||||
extends your existing key.
|
||||
</p>
|
||||
|
||||
<h2>4. Withdrawal and refunds</h2>
|
||||
<p>
|
||||
If you are a consumer in the EU/EEA, you have a statutory 14-day right of withdrawal. Beyond
|
||||
that, we keep it simple: if you are unhappy for any reason within 14 days of receiving your
|
||||
access key, email
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a> and we will refund your
|
||||
contribution in full.
|
||||
</p>
|
||||
|
||||
<h2>5. Fair use</h2>
|
||||
<p>
|
||||
The access key is personal. Please don't publish or share it; keys that are clearly abused (e.g.
|
||||
shared publicly) may be revoked. If your key is revoked without cause, you are entitled to a
|
||||
pro-rata refund.
|
||||
</p>
|
||||
|
||||
<h2>6. Service and availability</h2>
|
||||
<p>
|
||||
Drizz.li is a personal open-source project, provided as-is. We work to keep it available and
|
||||
accurate, but we cannot guarantee uninterrupted availability or the correctness of forecasts -
|
||||
weather data is informational only (see the disclaimer in the imprint). If the supporter extras
|
||||
become permanently unavailable during a period you paid for, we will refund the remaining period
|
||||
on request.
|
||||
</p>
|
||||
|
||||
<h2>7. Data</h2>
|
||||
<p>
|
||||
How we handle your data (email, payment reference, bank transfer details) is described in the
|
||||
<a href={href('/legal/privacy')}>privacy policy</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Governing law</h2>
|
||||
<p>
|
||||
These terms are governed by the law of [Switzerland], without prejudice to mandatory consumer
|
||||
protection provisions of your country of residence.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<ProsePage
|
||||
title="Condiciones para colaboradores"
|
||||
subtitle="Última actualización: 1 de agosto de 2026"
|
||||
>
|
||||
<p>
|
||||
Drizz.li es gratuito. Estas condiciones cubren la aportación voluntaria de colaborador: un
|
||||
pequeño pago que ayuda a mantener el proyecto y que, como agradecimiento, desbloquea los extras.
|
||||
Aunque lo llamamos aportación, a cambio se desbloquean funciones.
|
||||
</p>
|
||||
|
||||
<h2>1. Qué obtienes</h2>
|
||||
<p>
|
||||
Una aportación desbloquea los extras durante el periodo pagado: actualmente el clima histórico
|
||||
con comparación frente a las normales climáticas, además de las nuevas funciones que vayan
|
||||
llegando. Las partes gratuitas de Drizz.li siguen siendo gratuitas para todos.
|
||||
</p>
|
||||
|
||||
<h2>2. Cómo funciona</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Solicitas los datos de la transferencia en la página de registro; te los enviamos por correo
|
||||
junto con una referencia de pago personal.
|
||||
</li>
|
||||
<li>
|
||||
Transfieres el importe (desde 3 € / 3 $ / 3 CHF al mes) mediante una transferencia bancaria
|
||||
ordinaria, incluyendo la referencia.
|
||||
</li>
|
||||
<li>
|
||||
Las transferencias se concilian manualmente. Cuando llega tu aportación - normalmente en 24
|
||||
horas, como mucho unos días - recibes por correo una clave de acceso personal.
|
||||
</li>
|
||||
<li>
|
||||
Pegas la clave en Drizz.li una vez; se guarda en tu dispositivo y se verifica automáticamente.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Sin renovación automática</h2>
|
||||
<p>
|
||||
Las aportaciones son únicas y prepagadas: nada se renueva solo y nunca te cobramos. Cuando
|
||||
termina tu periodo, los extras simplemente se bloquean de nuevo; volver a aportar con el mismo
|
||||
correo amplía tu clave existente.
|
||||
</p>
|
||||
|
||||
<h2>4. Desistimiento y reembolsos</h2>
|
||||
<p>
|
||||
Si eres consumidor en la UE/EEE, dispones de un derecho legal de desistimiento de 14 días. Más
|
||||
allá de eso, lo mantenemos sencillo: si no estás satisfecho por cualquier motivo en los 14 días
|
||||
siguientes a recibir tu clave, escribe a
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
y te reembolsaremos la aportación íntegra.
|
||||
</p>
|
||||
|
||||
<h2>5. Uso razonable</h2>
|
||||
<p>
|
||||
La clave de acceso es personal. No la publiques ni la compartas; las claves claramente abusadas
|
||||
(por ejemplo, compartidas públicamente) pueden revocarse. Si tu clave se revoca sin causa,
|
||||
tienes derecho a un reembolso proporcional.
|
||||
</p>
|
||||
|
||||
<h2>6. Servicio y disponibilidad</h2>
|
||||
<p>
|
||||
Drizz.li es un proyecto personal de código abierto, ofrecido tal cual. Trabajamos para
|
||||
mantenerlo disponible y preciso, pero no podemos garantizar disponibilidad ininterrumpida ni la
|
||||
exactitud de los pronósticos: los datos meteorológicos son solo informativos (véase el descargo
|
||||
del aviso legal). Si los extras dejan de estar disponibles de forma permanente durante un
|
||||
periodo que has pagado, reembolsaremos el periodo restante a petición.
|
||||
</p>
|
||||
|
||||
<h2>7. Datos</h2>
|
||||
<p>
|
||||
Cómo tratamos tus datos (correo, referencia de pago, datos de la transferencia) se describe en
|
||||
la
|
||||
<a href={href('/legal/privacy')}>política de privacidad</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Legislación aplicable</h2>
|
||||
<p>
|
||||
Estas condiciones se rigen por la legislación de [Suiza], sin perjuicio de las disposiciones
|
||||
imperativas de protección al consumidor de tu país de residencia.
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
En caso de discrepancia prevalece la versión inglesa de estas condiciones.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Conditions contributeur" subtitle="Dernière mise à jour : 1er août 2026">
|
||||
<p>
|
||||
Drizz.li est gratuit. Ces conditions portent sur la contribution facultative : un petit paiement
|
||||
qui aide à faire vivre le projet et qui, en remerciement, débloque les bonus. Même si nous
|
||||
parlons de contribution, des fonctionnalités sont débloquées en retour.
|
||||
</p>
|
||||
|
||||
<h2>1. Ce que vous obtenez</h2>
|
||||
<p>
|
||||
Une contribution débloque les bonus pour la période payée : actuellement la météo historique
|
||||
avec comparaison aux normales climatiques, ainsi que les nouvelles fonctions à venir. Les
|
||||
parties gratuites de Drizz.li le restent pour tout le monde.
|
||||
</p>
|
||||
|
||||
<h2>2. Fonctionnement</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Vous demandez les coordonnées bancaires sur la page d'inscription ; nous vous les envoyons par
|
||||
e-mail avec une référence de paiement personnelle.
|
||||
</li>
|
||||
<li>
|
||||
Vous virez le montant (à partir de 3 € / 3 $ / 3 CHF par mois) par virement bancaire
|
||||
ordinaire, en indiquant la référence.
|
||||
</li>
|
||||
<li>
|
||||
Les virements sont rapprochés manuellement. Dès réception de votre contribution - en général
|
||||
sous 24 heures, au plus quelques jours - vous recevez une clé d'accès personnelle par e-mail.
|
||||
</li>
|
||||
<li>
|
||||
Vous collez la clé dans Drizz.li une seule fois ; elle est stockée sur votre appareil et
|
||||
vérifiée automatiquement.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Pas de reconduction automatique</h2>
|
||||
<p>
|
||||
Les contributions sont ponctuelles et prépayées : rien ne se reconduit et nous ne vous prélevons
|
||||
jamais. À la fin de votre période, les bonus se reverrouillent simplement ; contribuer à nouveau
|
||||
avec la même adresse prolonge votre clé existante.
|
||||
</p>
|
||||
|
||||
<h2>4. Rétractation et remboursement</h2>
|
||||
<p>
|
||||
Si vous êtes consommateur dans l'UE/EEE, vous disposez d'un droit légal de rétractation de 14
|
||||
jours. Au-delà, nous faisons simple : si vous n'êtes pas satisfait, pour quelque raison que ce
|
||||
soit, dans les 14 jours suivant la réception de votre clé, écrivez à
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
et nous vous remboursons intégralement.
|
||||
</p>
|
||||
|
||||
<h2>5. Usage loyal</h2>
|
||||
<p>
|
||||
La clé d'accès est personnelle. Merci de ne pas la publier ni la partager ; les clés
|
||||
manifestement détournées (par exemple partagées publiquement) peuvent être révoquées. Si votre
|
||||
clé est révoquée sans motif, vous avez droit à un remboursement au prorata.
|
||||
</p>
|
||||
|
||||
<h2>6. Service et disponibilité</h2>
|
||||
<p>
|
||||
Drizz.li est un projet personnel open source, fourni en l'état. Nous nous efforçons de le
|
||||
maintenir disponible et exact, mais nous ne pouvons garantir ni une disponibilité ininterrompue
|
||||
ni l'exactitude des prévisions - les données météo sont purement informatives (voir
|
||||
l'avertissement dans les mentions légales). Si les bonus devenaient définitivement indisponibles
|
||||
pendant une période payée, nous rembourserions la période restante sur demande.
|
||||
</p>
|
||||
|
||||
<h2>7. Données</h2>
|
||||
<p>
|
||||
La façon dont nous traitons vos données (e-mail, référence de paiement, informations de
|
||||
virement) est décrite dans la
|
||||
<a href={href('/legal/privacy')}>politique de confidentialité</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Droit applicable</h2>
|
||||
<p>
|
||||
Ces conditions sont régies par le droit de la [Suisse], sans préjudice des dispositions
|
||||
impératives de protection des consommateurs de votre pays de résidence.
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
En cas de divergence, la version anglaise de ces conditions fait foi.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import ProsePage from '$lib/components/prose-page.svelte';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
</script>
|
||||
|
||||
<ProsePage title="Condizioni per i sostenitori" subtitle="Ultimo aggiornamento: 1 agosto 2026">
|
||||
<p>
|
||||
Drizz.li è gratuito. Queste condizioni riguardano il contributo facoltativo di sostegno: un
|
||||
piccolo pagamento che aiuta a mantenere vivo il progetto e che, come ringraziamento, sblocca gli
|
||||
extra. Anche se lo chiamiamo contributo, in cambio vengono sbloccate delle funzioni.
|
||||
</p>
|
||||
|
||||
<h2>1. Cosa ottieni</h2>
|
||||
<p>
|
||||
Un contributo sblocca gli extra per il periodo pagato: attualmente il meteo storico con
|
||||
confronto rispetto alle normali climatiche, oltre alle nuove funzioni man mano che arrivano. Le
|
||||
parti gratuite di Drizz.li restano gratuite per tutti.
|
||||
</p>
|
||||
|
||||
<h2>2. Come funziona</h2>
|
||||
<ul>
|
||||
<li>
|
||||
Richiedi i dati per il bonifico nella pagina di iscrizione; te li inviamo via e-mail insieme a
|
||||
un riferimento di pagamento personale.
|
||||
</li>
|
||||
<li>
|
||||
Effettui il bonifico (da 3 € / 3 $ / 3 CHF al mese) con un normale bonifico bancario,
|
||||
indicando il riferimento.
|
||||
</li>
|
||||
<li>
|
||||
I bonifici vengono abbinati manualmente. Appena il contributo arriva - di solito entro 24 ore,
|
||||
al massimo pochi giorni - ricevi via e-mail una chiave di accesso personale.
|
||||
</li>
|
||||
<li>
|
||||
Incolli la chiave in Drizz.li una volta sola; viene salvata sul tuo dispositivo e verificata
|
||||
automaticamente.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Nessun rinnovo automatico</h2>
|
||||
<p>
|
||||
I contributi sono una tantum e prepagati: nulla si rinnova da solo e non ti addebitiamo mai
|
||||
nulla. Alla fine del periodo gli extra si bloccano semplicemente di nuovo; un nuovo contributo
|
||||
con la stessa e-mail estende la chiave esistente.
|
||||
</p>
|
||||
|
||||
<h2>4. Recesso e rimborsi</h2>
|
||||
<p>
|
||||
Se sei un consumatore nell'UE/SEE, hai un diritto di recesso legale di 14 giorni. Oltre a
|
||||
questo, la teniamo semplice: se per qualsiasi motivo non sei soddisfatto entro 14 giorni dalla
|
||||
ricezione della chiave, scrivi a
|
||||
<a href="mailto:drizzli@vincentvanderwal.nl">drizzli@vincentvanderwal.nl</a>
|
||||
e ti rimborseremo l'intero contributo.
|
||||
</p>
|
||||
|
||||
<h2>5. Uso corretto</h2>
|
||||
<p>
|
||||
La chiave di accesso è personale. Non pubblicarla né condividerla; le chiavi chiaramente abusate
|
||||
(ad esempio condivise pubblicamente) possono essere revocate. Se la tua chiave viene revocata
|
||||
senza motivo, hai diritto a un rimborso proporzionale.
|
||||
</p>
|
||||
|
||||
<h2>6. Servizio e disponibilità</h2>
|
||||
<p>
|
||||
Drizz.li è un progetto personale open source, fornito così com'è. Lavoriamo per mantenerlo
|
||||
disponibile e accurato, ma non possiamo garantire né la continuità del servizio né la
|
||||
correttezza delle previsioni: i dati meteo sono solo informativi (vedi l'avvertenza nelle note
|
||||
legali). Se gli extra dovessero diventare permanentemente non disponibili durante un periodo
|
||||
pagato, rimborseremo su richiesta il periodo residuo.
|
||||
</p>
|
||||
|
||||
<h2>7. Dati</h2>
|
||||
<p>
|
||||
Il trattamento dei tuoi dati (e-mail, riferimento di pagamento, dati del bonifico) è descritto
|
||||
nell'
|
||||
<a href={href('/legal/privacy')}>informativa sulla privacy</a>.
|
||||
</p>
|
||||
|
||||
<h2>8. Legge applicabile</h2>
|
||||
<p>
|
||||
Queste condizioni sono regolate dal diritto della [Svizzera], fatte salve le disposizioni
|
||||
imperative di tutela del consumatore del tuo paese di residenza.
|
||||
</p>
|
||||
|
||||
<p class="text-muted-foreground">
|
||||
In caso di discrepanza prevale la versione inglese di queste condizioni.
|
||||
</p>
|
||||
</ProsePage>
|
||||
@@ -327,8 +327,7 @@
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
This model's ensemble only reaches about <strong>{validDays} days</strong> ahead, the spread is
|
||||
trimmed to its available range.
|
||||
{m.ensemble_trimmed({ days: validDays })}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -385,7 +384,9 @@
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
groupRange,
|
||||
isColumnUnit
|
||||
} from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type DaylightBand,
|
||||
type ModelCompareResult,
|
||||
@@ -315,10 +316,10 @@
|
||||
<!-- Range / zoom controls, aligned with the title like the other pages -->
|
||||
<div class="lg:absolute lg:right-0 lg:top-20 z-40 flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]">Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
{#if zoomActive}
|
||||
<button
|
||||
@@ -336,13 +337,13 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4 4v6h6M20 20v-6h-6" />
|
||||
<path stroke-linecap="round" d="M20 10a8 8 0 0 0-14-4M4 14a8 8 0 0 0 14 4" />
|
||||
</svg>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Chart time range"
|
||||
aria-label={m.range_group_aria()}
|
||||
>
|
||||
{#each rangePresets as preset (preset.label)}
|
||||
<button
|
||||
@@ -426,7 +427,9 @@
|
||||
{#snippet controls()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none">Show legend</Label>
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
</ChartToolbar>
|
||||
@@ -436,7 +439,9 @@
|
||||
|
||||
<div class="mt-4 md:mt-8">
|
||||
<div class="flex">
|
||||
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
|
||||
<a href="#models"
|
||||
><h2 id="models" class="text-2xl md:text-3xl">{m.compare_models_heading()}</h2></a
|
||||
>
|
||||
{#if params.models && params.models.length > 0}
|
||||
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
|
||||
<div
|
||||
@@ -487,7 +492,7 @@
|
||||
<div class="flex">
|
||||
<a href="#hourly_weather_variables"
|
||||
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
|
||||
Hourly Weather Variables
|
||||
{m.compare_variables_heading()}
|
||||
</h2></a
|
||||
>
|
||||
{#if params.hourly && params.hourly.length > 0}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
|
||||
interface Props {
|
||||
@@ -69,14 +71,14 @@
|
||||
{#if displayModels.length > 0}
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
|
||||
<h3 class="text-xl font-bold">{m.compare_timeline_title()}</h3>
|
||||
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<span class="select-none text-muted-foreground">3h</span>
|
||||
<button
|
||||
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||
title="Toggle between 1-hour and 3-hour intervals"
|
||||
title={m.interval_toggle()}
|
||||
>
|
||||
<span
|
||||
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||
@@ -96,7 +98,7 @@
|
||||
<th
|
||||
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
|
||||
>
|
||||
Model
|
||||
{m.compare_model_label()}
|
||||
</th>
|
||||
{#each filteredIndices as idx, i (idx)}
|
||||
{@const ts = timestamps[idx]}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import {
|
||||
storedArchiveModel,
|
||||
storedChartLayout,
|
||||
storedLocation,
|
||||
storedUnits,
|
||||
@@ -13,6 +15,7 @@
|
||||
|
||||
import { ChartContainer } from '$lib/components/charts';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
@@ -22,8 +25,10 @@
|
||||
fetchHistoricalWeather
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { defaultParameters } from '../../options';
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { archiveModelGroups, defaultParameters } from '../../options';
|
||||
import HourlyTable from '../../week/[location]/HourlyTable.svelte';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import { neededHourlyApiVars } from '../../week/[location]/variables';
|
||||
import DateRangeControls from './DateRangeControls.svelte';
|
||||
import HistoricalDaily from './HistoricalDaily.svelte';
|
||||
@@ -37,6 +42,8 @@
|
||||
// the page cross-fade waits for this before revealing the new page
|
||||
reportPageReady(() => result != null);
|
||||
|
||||
useHeroActions(heroActions);
|
||||
|
||||
let location = $derived(data.location);
|
||||
$effect(() => {
|
||||
storedLocation.set(data.location);
|
||||
@@ -79,6 +86,11 @@
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
let archiveModel = $state('best_match');
|
||||
onMount(() => {
|
||||
archiveModel = get(storedArchiveModel);
|
||||
});
|
||||
|
||||
function onRangeChange(s: string, e: string) {
|
||||
startDate = s;
|
||||
endDate = e;
|
||||
@@ -101,6 +113,8 @@
|
||||
const s = startDate;
|
||||
const e = endDate;
|
||||
const vars = hourlyVars;
|
||||
const model = archiveModel;
|
||||
void model;
|
||||
if (!mounted || !$isSupporter || !loc || !s || !e) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
@@ -203,12 +217,27 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- the reanalysis picker rides in the layout's location row -->
|
||||
{#snippet heroActions()}
|
||||
<div class="lg:absolute lg:right-0 lg:top-0 flex w-full min-w-0 items-center gap-3 sm:w-auto">
|
||||
<ModelSelector
|
||||
selectedModel={archiveModel}
|
||||
groups={archiveModelGroups}
|
||||
label={m.model_archive()}
|
||||
onModelChange={(model) => {
|
||||
archiveModel = model;
|
||||
storedArchiveModel.set(model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Historical weather</title>
|
||||
<title>Drizz.li | {m.page_historical_subtitle()}</title>
|
||||
<meta name="description" content="Past weather and climate-normal comparisons for any location" />
|
||||
</svelte:head>
|
||||
|
||||
<PaywallGate feature="Historical weather">
|
||||
<PaywallGate feature={m.page_historical_subtitle()}>
|
||||
<DateRangeControls
|
||||
start={startDate}
|
||||
end={endDate}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
start: string;
|
||||
end: string;
|
||||
@@ -34,10 +36,10 @@
|
||||
}
|
||||
|
||||
const presets = [
|
||||
{ label: '7 days', apply: () => applyLastDays(7) },
|
||||
{ label: '30 days', apply: () => applyLastDays(30) },
|
||||
{ label: '90 days', apply: () => applyLastDays(90) },
|
||||
{ label: 'Month, last year', apply: applyThisMonthLastYear }
|
||||
{ label: () => m.historical_last_days({ days: 7 }), apply: () => applyLastDays(7) },
|
||||
{ label: () => m.historical_last_days({ days: 30 }), apply: () => applyLastDays(30) },
|
||||
{ label: () => m.historical_last_days({ days: 90 }), apply: () => applyLastDays(90) },
|
||||
{ label: () => m.historical_month_last_year(), apply: applyThisMonthLastYear }
|
||||
];
|
||||
|
||||
// ─── Manual inputs ────────────────────────────────────────────────────────────
|
||||
@@ -65,7 +67,9 @@
|
||||
>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div class="grid gap-1">
|
||||
<label for="hist-start" class="text-xs font-semibold text-muted-foreground">From</label>
|
||||
<label for="hist-start" class="text-xs font-semibold text-muted-foreground"
|
||||
>{m.historical_from()}</label
|
||||
>
|
||||
<input
|
||||
id="hist-start"
|
||||
type="date"
|
||||
@@ -77,7 +81,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-1">
|
||||
<label for="hist-end" class="text-xs font-semibold text-muted-foreground">To</label>
|
||||
<label for="hist-end" class="text-xs font-semibold text-muted-foreground"
|
||||
>{m.historical_to()}</label
|
||||
>
|
||||
<input
|
||||
id="hist-end"
|
||||
type="date"
|
||||
@@ -93,15 +99,15 @@
|
||||
<div
|
||||
class="inline-flex flex-wrap items-center gap-0.5 rounded-lg bg-muted p-0.5 text-xs font-semibold"
|
||||
role="group"
|
||||
aria-label="Quick ranges"
|
||||
aria-label={m.historical_quick_ranges()}
|
||||
>
|
||||
{#each presets as preset (preset.label)}
|
||||
{#each presets as preset (preset.label())}
|
||||
<button
|
||||
type="button"
|
||||
class="cursor-pointer rounded-md px-2.5 py-1.5 whitespace-nowrap text-muted-foreground transition-colors hover:bg-background hover:text-foreground hover:shadow-sm"
|
||||
onclick={preset.apply}
|
||||
>
|
||||
{preset.label}
|
||||
{preset.label()}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { formatZoned, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import {
|
||||
type ClimateNormals,
|
||||
type HistoricalDailyData,
|
||||
@@ -104,7 +105,7 @@
|
||||
<!-- ─── KPI tiles ────────────────────────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 gap-2.5 lg:grid-cols-4">
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Average temperature</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_avg_temperature()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">
|
||||
{fmtTemp(stats.avg)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
@@ -118,15 +119,15 @@
|
||||
class:dark:text-red-400={stats.tempAnomaly >= 0}
|
||||
class:dark:text-blue-400={stats.tempAnomaly < 0}
|
||||
>
|
||||
{fmtSigned(stats.tempAnomaly)} vs normal
|
||||
{m.anomaly_vs_normal({ value: fmtSigned(stats.tempAnomaly) })}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">1991–2020 normal loading…</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{m.normals_loading_period()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Total precipitation</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_total_precipitation()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtPrecip(stats.totalPrecip)}</p>
|
||||
{#if stats.normalPrecip != null}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -140,7 +141,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Warmest day</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_warmest_day()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.warm.t)}</p>
|
||||
{#if stats.warm.i >= 0}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -150,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border/70 bg-card p-3.5 shadow-sm">
|
||||
<p class="text-xs font-medium text-muted-foreground">Coldest day</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_coldest_day()}</p>
|
||||
<p class="mt-1 text-2xl font-bold tabular-nums">{fmtTemp(stats.cold.t)}</p>
|
||||
{#if stats.cold.i >= 0}
|
||||
<p class="mt-0.5 text-xs font-semibold text-muted-foreground">
|
||||
@@ -166,12 +167,12 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border/70 bg-muted/40 px-4 py-2.5">
|
||||
<h3 class="text-base font-bold">
|
||||
Daily <span class="font-semibold text-muted-foreground">– select a day for hourly detail</span
|
||||
>
|
||||
{m.historical_daily_heading()}
|
||||
<span class="font-semibold text-muted-foreground">{m.historical_daily_hint()}</span>
|
||||
</h3>
|
||||
{#if normals}
|
||||
<span class="hidden text-xs text-muted-foreground sm:inline">
|
||||
normal band = 1991–2020 mean
|
||||
{m.normal_band_legend()}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -242,7 +243,7 @@
|
||||
<span
|
||||
class="mt-0.5 inline-block h-1.5 w-6 rounded-full"
|
||||
style="background:{anomalyColor(anomaly)}"
|
||||
title="{fmtSigned(anomaly)} vs normal"
|
||||
title={m.anomaly_vs_normal({ value: fmtSigned(anomaly) })}
|
||||
></span>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { ChartContainer, downloadChartsPng } from '$lib/components/charts';
|
||||
|
||||
import { CanvasChart, groupRange } from '$lib/charts';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { type FetchedHourly, type WeatherUnits } from '../../week/[location]/types';
|
||||
@@ -121,22 +122,23 @@
|
||||
<section class="mt-8" transition:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 class="text-lg font-bold">
|
||||
Meteograms <span class="font-semibold text-muted-foreground">– full range</span>
|
||||
{m.meteograms_heading()}
|
||||
<span class="font-semibold text-muted-foreground">{m.historical_full_range()}</span>
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
+ scroll to zoom
|
||||
{m.meteograms_zoom_hint_end()}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={liveCharts.length === 0 || downloadingPng}
|
||||
onclick={downloadPng}
|
||||
title="Download meteogram as PNG image"
|
||||
title={m.chart_download()}
|
||||
>
|
||||
{#if downloadingPng}Rendering…{:else}PNG{/if}
|
||||
</button>
|
||||
@@ -148,7 +150,7 @@
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-primary/50 bg-primary/10 px-2.5 py-1 text-xs font-semibold text-primary transition-colors hover:bg-primary/15"
|
||||
onclick={resetZoom}
|
||||
>
|
||||
Reset zoom
|
||||
{m.reset_zoom()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -158,7 +160,7 @@
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No meteograms configured. Add variables from the 7-day forecast page.
|
||||
{m.meteograms_none_historical()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="-mx-3 border-y border-border/70 bg-card shadow-sm lg:mx-0 lg:rounded-2xl lg:border">
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
import { mapsDomainForModel } from '$lib/utils/maps-domain';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
// Hash piping, both directions:
|
||||
// - inbound: a #zoom/lat/lng(/bearing/pitch) hash on OUR url seeds the
|
||||
// map, so positions can be bookmarked/shared via drizzli links
|
||||
@@ -74,7 +76,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Weather Map | Open-Meteo.com</title>
|
||||
<title>{m.page_maps_title()} | Open-Meteo.com</title>
|
||||
<link rel="canonical" href="https://open-meteo.com/weather/maps" />
|
||||
<meta name="description" content="Interactive weather map powered by Open-Meteo" />
|
||||
</svelte:head>
|
||||
@@ -88,7 +90,7 @@
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={iframeSrc}
|
||||
title="Open-Meteo Interactive Map"
|
||||
title={m.maps_iframe_title()}
|
||||
loading="lazy"
|
||||
allowfullscreen
|
||||
allow="cross-origin-isolated"
|
||||
|
||||
@@ -513,3 +513,48 @@ export function inDomainCity(model: string): { slug: string; label: string } | n
|
||||
const hit = IN_DOMAIN_CITY.find(([prefix]) => model.startsWith(prefix));
|
||||
return hit ? hit[1] : null;
|
||||
}
|
||||
|
||||
/** Reanalyses the archive API accepts (verified against archive-api.open-meteo.com). */
|
||||
export const archiveModelGroups: WeatherModelGroup[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Automatic',
|
||||
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'daily' }]
|
||||
},
|
||||
{
|
||||
value: 'era5',
|
||||
label: 'ECMWF reanalysis',
|
||||
models: [
|
||||
{ value: 'era5_seamless', label: 'ERA5 seamless', resolution: '9-25 km', update: 'daily' },
|
||||
{ value: 'era5', label: 'ERA5', resolution: '25 km', update: 'daily' },
|
||||
{ value: 'era5_land', label: 'ERA5-Land', resolution: '9 km', update: 'daily' },
|
||||
{ value: 'ecmwf_ifs', label: 'ECMWF IFS', resolution: '9 km', update: 'daily' }
|
||||
]
|
||||
},
|
||||
{
|
||||
value: 'regional',
|
||||
label: 'Regional reanalysis',
|
||||
models: [{ value: 'cerra', label: 'CERRA (Europe)', resolution: '5 km', update: 'daily' }]
|
||||
}
|
||||
];
|
||||
|
||||
/** Seasonal models the seasonal API accepts. */
|
||||
export const seasonalModelGroups: WeatherModelGroup[] = [
|
||||
{
|
||||
value: 'auto',
|
||||
label: 'Automatic',
|
||||
models: [{ value: 'best_match', label: 'Best match', resolution: 'varies', update: 'monthly' }]
|
||||
},
|
||||
{
|
||||
value: 'ecmwf',
|
||||
label: 'ECMWF',
|
||||
models: [
|
||||
{
|
||||
value: 'ecmwf_seasonal_seamless',
|
||||
label: 'ECMWF SEAS5',
|
||||
resolution: '36 km',
|
||||
update: 'monthly'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { get } from 'svelte/store';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { reportPageReady } from '$lib/stores/page-transition.svelte';
|
||||
import { storedLocation, storedUnits } from '$lib/stores/settings';
|
||||
import { storedLocation, storedSeasonalModel, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import { ChartContainer, ChartToolbar } from '$lib/components/charts';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import PaywallGate from '$lib/paywall/PaywallGate.svelte';
|
||||
import { isSupporter } from '$lib/paywall/supporter';
|
||||
import {
|
||||
@@ -19,7 +21,8 @@
|
||||
} from '$lib/services/weather';
|
||||
|
||||
import { useHeroActions } from '../../hero.svelte';
|
||||
import { defaultParameters } from '../../options';
|
||||
import { defaultParameters, seasonalModelGroups } from '../../options';
|
||||
import ModelSelector from '../../week/[location]/ModelSelector.svelte';
|
||||
import { getPrecipUnit } from '../../week/[location]/types';
|
||||
import SeasonalCharts from './SeasonalCharts.svelte';
|
||||
import SeasonalMonths from './SeasonalMonths.svelte';
|
||||
@@ -79,7 +82,9 @@
|
||||
let result = $state<SeasonalForecastResult | null>(null);
|
||||
let normals = $state<ClimateNormals | null>(null);
|
||||
|
||||
let seasonalModel = $state('best_match');
|
||||
onMount(() => {
|
||||
seasonalModel = get(storedSeasonalModel);
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
@@ -89,6 +94,7 @@
|
||||
const loc = location;
|
||||
const tempUnit = params.temperature_unit;
|
||||
const precipUnit = params.precipitation_unit;
|
||||
const model = seasonalModel;
|
||||
if (!mounted || !$isSupporter || !loc) return;
|
||||
|
||||
const version = ++requestVersion;
|
||||
@@ -99,6 +105,7 @@
|
||||
latitude: loc.latitude!,
|
||||
longitude: loc.longitude!,
|
||||
dailyVariables: SEASONAL_VARS,
|
||||
model,
|
||||
temperature_unit: tempUnit as 'celsius' | 'fahrenheit',
|
||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||
precipitation_unit: precipUnit as 'mm' | 'inch',
|
||||
@@ -173,7 +180,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Seasonal forecast</title>
|
||||
<title>Drizz.li | {m.page_seasonal_subtitle()}</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Multi-month seasonal outlook: monthly temperature and precipitation trends against the 1991-2020 climate normal"
|
||||
@@ -182,32 +189,43 @@
|
||||
|
||||
<!-- the range buttons ride in the layout's location row (see weather/+layout) -->
|
||||
{#snippet heroActions()}
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label="Outlook range"
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex w-full flex-wrap items-center gap-3 sm:w-auto">
|
||||
{#if result}
|
||||
<!-- range buttons reslice the already-fetched horizon (no refetch) -->
|
||||
<div
|
||||
class="flex w-full gap-1 rounded-lg border border-border bg-card p-1 sm:w-auto"
|
||||
role="group"
|
||||
aria-label={m.seasonal_range_aria()}
|
||||
>
|
||||
{#each RANGES as range, i (range.label)}
|
||||
{@const disabled = range.days !== Infinity && range.days > horizonDays}
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 cursor-pointer rounded-md px-3 py-1.5 text-xs font-semibold whitespace-nowrap transition-colors sm:flex-none {rangeIndex ===
|
||||
i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'} {disabled
|
||||
? 'cursor-not-allowed opacity-40'
|
||||
: ''}"
|
||||
aria-pressed={rangeIndex === i}
|
||||
{disabled}
|
||||
onclick={() => (rangeIndex = i)}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<ModelSelector
|
||||
selectedModel={seasonalModel}
|
||||
groups={seasonalModelGroups}
|
||||
label={m.model_seasonal()}
|
||||
onModelChange={(model) => {
|
||||
seasonalModel = model;
|
||||
storedSeasonalModel.set(model);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<!-- What a seasonal forecast is (and is not): without this the daily-looking
|
||||
@@ -226,17 +244,16 @@
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</svg>
|
||||
<p class="text-muted-foreground">
|
||||
A seasonal forecast shows how a whole month is likely to
|
||||
<strong class="font-semibold text-foreground">depart from its climate normal</strong> - not the
|
||||
weather on any given day. Read the monthly trend and the ensemble agreement, not the daily
|
||||
wiggles.
|
||||
{m.seasonal_explainer_before()}
|
||||
<strong class="font-semibold text-foreground">{m.seasonal_explainer_strong()}</strong>
|
||||
{m.seasonal_explainer_after()}
|
||||
{#if lastDayLabel}
|
||||
This outlook runs to <strong class="font-semibold text-foreground">{lastDayLabel}</strong>.
|
||||
{m.seasonal_runs_to({ date: lastDayLabel })}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<PaywallGate feature="The seasonal outlook">
|
||||
<PaywallGate feature={m.page_seasonal_subtitle()}>
|
||||
{#if loadError}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
|
||||
@@ -265,7 +282,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
|
||||
<Label for="show_legend" class="cursor-pointer text-base leading-none"
|
||||
>Show legend</Label
|
||||
>{m.legend_show()}</Label
|
||||
>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getColor } from '../../utils/colors';
|
||||
import { type WeatherUnits, getPrecipUnit, getTempUnit } from '../../week/[location]/types';
|
||||
|
||||
@@ -50,7 +52,7 @@
|
||||
<!-- headline: mean temperature and its departure from the 1991-2020 normal -->
|
||||
<div class="mt-2.5 flex items-end gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-muted-foreground">Mean temperature</p>
|
||||
<p class="text-xs font-medium text-muted-foreground">{m.stat_mean_temperature()}</p>
|
||||
<p class="text-2xl leading-tight font-bold tabular-nums">
|
||||
{fmtTemp(month.tMean)}<span class="text-base font-semibold text-muted-foreground"
|
||||
>{tempUnit.replace('°', '')}</span
|
||||
@@ -70,9 +72,9 @@
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">{anomalyLabel(month.anomaly)}</p>
|
||||
{:else if normals}
|
||||
<p class="text-[11px] text-muted-foreground">no normal for this month</p>
|
||||
<p class="text-[11px] text-muted-foreground">{m.seasonal_no_normal()}</p>
|
||||
{:else}
|
||||
<p class="text-[11px] text-muted-foreground">normal loading…</p>
|
||||
<p class="text-[11px] text-muted-foreground">{m.normals_loading()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,7 +87,7 @@
|
||||
style="background:{getColor(month.tMax, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMax)}</span>
|
||||
<span class="text-muted-foreground">day</span>
|
||||
<span class="text-muted-foreground">{m.label_day()}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -93,14 +95,14 @@
|
||||
style="background:{getColor(month.tMin, units.temperature_unit)}"
|
||||
></span>
|
||||
<span class="font-semibold tabular-nums">{fmtTemp(month.tMin)}</span>
|
||||
<span class="text-muted-foreground">night</span>
|
||||
<span class="text-muted-foreground">{m.label_night()}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- precipitation against its own normal, as a share bar -->
|
||||
<div class="mt-3 border-t border-border/60 pt-2.5">
|
||||
<div class="flex items-baseline justify-between gap-2 text-xs">
|
||||
<span class="font-medium text-muted-foreground">Precipitation</span>
|
||||
<span class="font-medium text-muted-foreground">{m.var_precipitation()}</span>
|
||||
<span class="font-semibold tabular-nums">
|
||||
{fmtPrecip(month.precip)}
|
||||
{precipUnit}
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import HourlyTable from './HourlyTable.svelte';
|
||||
import MeteogramCharts from './MeteogramCharts.svelte';
|
||||
import ModelSelector from './ModelSelector.svelte';
|
||||
import NearbyCities from './NearbyCities.svelte';
|
||||
import VariableSidebar from './VariableSidebar.svelte';
|
||||
import { neededHourlyApiVars } from './variables';
|
||||
|
||||
@@ -326,7 +327,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Drizz.li | Weather</title>
|
||||
<title>Drizz.li | {m.page_week_title()}</title>
|
||||
<link rel="canonical" href="https://drizz.li/weather/week" />
|
||||
<meta name="description" content="7-day weather forecast with detailed hourly data" />
|
||||
</svelte:head>
|
||||
@@ -362,7 +363,7 @@
|
||||
class="cursor-pointer rounded-md border border-destructive/40 bg-background px-3 py-1 text-xs font-semibold text-destructive transition-colors hover:bg-destructive/10"
|
||||
onclick={() => retryNonce++}
|
||||
>
|
||||
Try again
|
||||
{m.action_try_again()}
|
||||
</button>
|
||||
{#if suggestedCity}
|
||||
<a
|
||||
@@ -383,7 +384,7 @@
|
||||
</div>
|
||||
{#if loadError.detail}
|
||||
<details class="mt-2 text-xs text-destructive/70">
|
||||
<summary class="cursor-pointer select-none">Technical details</summary>
|
||||
<summary class="cursor-pointer select-none">{m.error_technical_details()}</summary>
|
||||
<p class="mt-1 font-mono break-all">{loadError.detail}</p>
|
||||
</details>
|
||||
{/if}
|
||||
@@ -535,6 +536,15 @@
|
||||
<ChartContainer loading chartCount={enabledChartCount || 1} {chartHeight} />
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if selectedDayKey}
|
||||
<NearbyCities
|
||||
latitude={location.latitude}
|
||||
longitude={location.longitude}
|
||||
{selectedDayKey}
|
||||
units={$storedUnits}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -152,15 +152,15 @@
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 class="text-base font-bold">Customize meteograms</h2>
|
||||
<h2 class="text-base font-bold">{m.customize_meteograms()}</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Drag variables between charts to build your own layout.
|
||||
{m.customizer_intro()}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label="Close"
|
||||
aria-label={m.action_close()}
|
||||
>
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
@@ -212,12 +212,12 @@
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-[11px] font-bold tracking-wider text-primary uppercase"
|
||||
>Chart {i + 1}</span
|
||||
>{m.customizer_chart_n({ number: i + 1 })}</span
|
||||
>
|
||||
<button
|
||||
class="cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
|
||||
onclick={() => deletePanel(panel.id)}
|
||||
aria-label="Delete chart {i + 1}"
|
||||
aria-label={m.customizer_delete_chart({ number: i + 1 })}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
@@ -238,7 +238,7 @@
|
||||
data-chip
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Drag {def.label}"
|
||||
aria-label={m.customizer_drag({ variable: def.label })}
|
||||
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background py-1.5 pr-1 pl-2.5 text-sm shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||
key
|
||||
? 'opacity-30'
|
||||
@@ -257,7 +257,7 @@
|
||||
class="ml-0.5 flex h-5 w-5 cursor-pointer items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onpointerdown={(e) => e.stopPropagation()}
|
||||
onclick={() => removeVar(key)}
|
||||
aria-label="Remove {def.label}"
|
||||
aria-label={m.customizer_remove({ variable: def.label })}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -274,7 +274,7 @@
|
||||
{/each}
|
||||
{#if panel.variables.length === 0}
|
||||
<span class="self-center text-xs text-muted-foreground italic"
|
||||
>Drop variables here</span
|
||||
>{m.customizer_drop_here()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -285,12 +285,12 @@
|
||||
class="w-full cursor-pointer rounded-xl border-2 border-dashed border-border py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
onclick={addPanel}
|
||||
>
|
||||
+ Add chart
|
||||
{m.customizer_add_chart()}
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-muted-foreground uppercase">
|
||||
Available variables
|
||||
{m.customizer_available()}
|
||||
</h3>
|
||||
<div
|
||||
data-zone="pool"
|
||||
@@ -304,7 +304,7 @@
|
||||
data-chip
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label="Drag {def.label}"
|
||||
aria-label={m.customizer_drag({ variable: def.label })}
|
||||
class="flex cursor-grab touch-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 py-1.5 text-sm text-muted-foreground shadow-sm select-none active:cursor-grabbing {dragKey ===
|
||||
def.key
|
||||
? 'opacity-30'
|
||||
@@ -321,7 +321,7 @@
|
||||
{/each}
|
||||
{#if availableVars.length === 0}
|
||||
<span class="self-center text-xs text-muted-foreground italic"
|
||||
>All variables are in use</span
|
||||
>{m.customizer_all_in_use()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -333,13 +333,13 @@
|
||||
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onclick={resetLayout}
|
||||
>
|
||||
Reset to defaults
|
||||
{m.action_reset_defaults()}
|
||||
</button>
|
||||
<button
|
||||
class="cursor-pointer rounded-lg bg-primary px-4 py-1.5 text-sm font-semibold text-primary-foreground transition-opacity hover:opacity-90"
|
||||
onclick={onClose}
|
||||
>
|
||||
Done
|
||||
{m.action_done()}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
import { formatZoned, getRelativeDayLabel, isSameDayInZone } from '$lib/utils/date';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
import { getTempStyle } from '../../utils/colors';
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
import { precipIsSignificant, sunIsSignificant, windIsSignificant } from './significance';
|
||||
@@ -118,7 +120,7 @@
|
||||
type="button"
|
||||
class="mr-4 flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground"
|
||||
onclick={onExtendPast}
|
||||
aria-label="Load recent past days"
|
||||
aria-label={m.strip_past_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -135,7 +137,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M14 13l-3 3 3 3" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Past<br />3 days
|
||||
{m.daycards_past()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -317,7 +319,7 @@
|
||||
type="button"
|
||||
class="day-card-btn flex w-24 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:hidden"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -334,7 +336,7 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">
|
||||
Load<br />15 days
|
||||
{m.daycards_load_15()}
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -349,7 +351,7 @@
|
||||
type="button"
|
||||
class="mt-2 mb-3 hidden w-20 shrink-0 cursor-pointer flex-col items-center justify-center gap-2 self-stretch rounded-2xl border border-dashed border-border/70 bg-card/40 px-2 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-foreground md:mt-5 md:mb-11 md:flex md:w-24"
|
||||
onclick={onExtend}
|
||||
aria-label="Load the longer-range forecast"
|
||||
aria-label={m.strip_extend_aria()}
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
@@ -365,7 +367,8 @@
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 14v4m-2-2h4" />
|
||||
</svg>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold"> Load<br />15 days </span>
|
||||
<span class="text-center text-[11px] leading-tight font-semibold">{m.daycards_load_15()}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -451,7 +451,7 @@
|
||||
<button
|
||||
class="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 text-[13px] font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||
onclick={onCustomize}
|
||||
aria-label="Customize variables"
|
||||
aria-label={m.table_customize()}
|
||||
>
|
||||
<!-- sliders icon -->
|
||||
<svg
|
||||
@@ -466,13 +466,13 @@
|
||||
d="M4 6h9m5 0h2M4 12h2m5 0h9M4 18h9m5 0h2M13 4.5v3M6 10.5v3M18 16.5v3"
|
||||
/>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Variables</span>
|
||||
<span class="hidden sm:inline">{m.hourly_variables()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<div
|
||||
class="inline-flex items-center rounded-lg bg-muted p-0.5 text-[13px] font-semibold"
|
||||
role="group"
|
||||
aria-label="Hourly interval"
|
||||
aria-label={m.table_interval_aria()}
|
||||
>
|
||||
{#each [3, 1] as interval (interval)}
|
||||
<button
|
||||
@@ -584,7 +584,7 @@
|
||||
class="absolute bottom-0.5 z-15 -translate-x-1/2 rounded-full bg-red-500 px-1.5 py-px text-[9px] font-bold tracking-wide whitespace-nowrap text-white uppercase shadow-sm"
|
||||
style="left:{nowPercent}%"
|
||||
>
|
||||
Now
|
||||
{m.table_now()}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- Hour labels -->
|
||||
|
||||
@@ -231,7 +231,7 @@
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span class="hidden text-xs text-muted-foreground md:inline">
|
||||
drag or
|
||||
{m.meteograms_zoom_hint()}
|
||||
<kbd class="rounded border border-border bg-muted px-1 py-0.5 font-sans text-[10px]"
|
||||
>Ctrl</kbd
|
||||
>
|
||||
@@ -273,7 +273,7 @@
|
||||
class="flex cursor-pointer items-center gap-1.5 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-semibold text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={liveCharts.length === 0 || downloadingPng}
|
||||
onclick={downloadPng}
|
||||
title="Download meteogram as PNG image"
|
||||
title={m.chart_download()}
|
||||
>
|
||||
{#if downloadingPng}
|
||||
<svg
|
||||
@@ -330,9 +330,10 @@
|
||||
<div
|
||||
class="rounded-xl border border-dashed border-border px-4 py-10 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No meteograms configured, <button
|
||||
{m.meteograms_none_before()}
|
||||
<button
|
||||
class="cursor-pointer font-semibold text-primary underline-offset-2 hover:underline"
|
||||
onclick={() => (customizerOpen = true)}>add some variables</button
|
||||
onclick={() => (customizerOpen = true)}>{m.meteograms_none_action()}</button
|
||||
>.
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
}}
|
||||
>
|
||||
<Select.Trigger
|
||||
aria-label="{label} selection"
|
||||
aria-label={m.model_selector_aria({ label })}
|
||||
class="group h-auto min-h-12 min-w-0 flex-1 cursor-pointer gap-2.5 rounded-xl border-2 border-primary/35 bg-card py-1.5 ps-2.5 shadow-sm transition-colors hover:border-primary/70 hover:shadow-md data-[size=default]:h-auto data-[state=open]:border-primary sm:min-h-14 sm:gap-3 sm:py-2 sm:min-w-72 sm:flex-none"
|
||||
>
|
||||
<div
|
||||
@@ -99,7 +99,9 @@
|
||||
{mo.resolution}{mo.update ? ` · updated ${mo.update}` : ''}
|
||||
</span>
|
||||
{:else if mo.value === 'best_match'}
|
||||
<span class="text-[11px] text-muted-foreground">Automatic selection</span>
|
||||
<span class="text-[11px] text-muted-foreground"
|
||||
>{m.model_automatic_selection()}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</Select.Item>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import { href } from '$lib/i18n';
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import { getLocale } from '$lib/paraglide/runtime';
|
||||
import { type NearbyCity, findNearbyCities } from '$lib/services/nearby-cities';
|
||||
import { type NearbyDaily, fetchNearbyDaily } from '$lib/services/weather';
|
||||
|
||||
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||
|
||||
import type { UnitPrefs } from '$lib/stores/settings';
|
||||
|
||||
interface Props {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** "yyyy-MM-dd" of the day the rest of the page is showing */
|
||||
selectedDayKey: string;
|
||||
units: UnitPrefs;
|
||||
}
|
||||
|
||||
let { latitude, longitude, selectedDayKey, units }: Props = $props();
|
||||
|
||||
const COUNT = 10;
|
||||
|
||||
let cities = $state<NearbyCity[]>([]);
|
||||
let daily = $state<(NearbyDaily | null)[]>([]);
|
||||
let failed = $state(false);
|
||||
|
||||
// Country names come free and localized from the platform; the flag images
|
||||
// are the same set the header uses.
|
||||
let countryNames = $derived(new Intl.DisplayNames([getLocale()], { type: 'region' }));
|
||||
const countryName = (code: string) => {
|
||||
try {
|
||||
return countryNames.of(code) ?? code;
|
||||
} catch {
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
// One request covers the whole strip's date range, so clicking through the
|
||||
// days re-reads what is already here instead of refetching ten cities.
|
||||
$effect(() => {
|
||||
const lat = latitude;
|
||||
const lon = longitude;
|
||||
const unitPrefs = { ...units };
|
||||
let cancelled = false;
|
||||
|
||||
cities = [];
|
||||
daily = [];
|
||||
failed = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const found = await findNearbyCities(lat, lon, COUNT);
|
||||
if (cancelled) return;
|
||||
cities = found;
|
||||
|
||||
const snapshots = await fetchNearbyDaily({ points: found, ...unitPrefs });
|
||||
if (cancelled) return;
|
||||
daily = snapshots;
|
||||
} catch {
|
||||
if (!cancelled) failed = true;
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
const dayFor = (index: number) => daily[index]?.byDate[selectedDayKey];
|
||||
|
||||
const temp = (value: number | undefined) =>
|
||||
value == null || !Number.isFinite(value) ? '–' : `${Math.round(value)}°`;
|
||||
|
||||
const distance = (km: number) =>
|
||||
units.wind_speed_unit === 'mph' ? `${Math.round(km * 0.621371)} mi` : `${Math.round(km)} km`;
|
||||
</script>
|
||||
|
||||
{#if cities.length > 0 && !failed}
|
||||
<section class="mt-8" in:fade={{ duration: 200 }}>
|
||||
<div class="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<h2 class="text-lg font-semibold">{m.nearby_cities_title()}</h2>
|
||||
<p class="text-xs text-muted-foreground">{m.nearby_cities_subtitle()}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="-mx-3 grid grid-cols-2 gap-px overflow-hidden border-y border-border/70 bg-border/70 sm:grid-cols-3 md:mx-0 md:rounded-2xl md:border lg:grid-cols-5"
|
||||
>
|
||||
{#each cities as city, i (city.id)}
|
||||
{@const day = dayFor(i)}
|
||||
<a
|
||||
href={href('/weather/week/[location]', {
|
||||
location: buildLocationRoute({
|
||||
id: city.id,
|
||||
name: city.name,
|
||||
latitude: city.latitude,
|
||||
longitude: city.longitude,
|
||||
population: city.population,
|
||||
feature_code: 'PPL'
|
||||
})
|
||||
})}
|
||||
class="group flex items-center gap-2 bg-card px-3 py-2.5 transition-colors hover:bg-muted/60"
|
||||
title="{city.name}, {countryName(city.countryCode)}"
|
||||
>
|
||||
<svg class="shrink-0 fill-foreground/80" width="34px" height="34px">
|
||||
{#if day}
|
||||
<use
|
||||
xlink:href="/images/weather-icons/{getWeatherIconName(
|
||||
day.weatherCode,
|
||||
true
|
||||
)}.svg#Layer_1"
|
||||
></use>
|
||||
{/if}
|
||||
</svg>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium group-hover:underline">{city.name}</div>
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
{distance(city.distanceKm)} · {countryName(city.countryCode)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-sm tabular-nums">
|
||||
<div class="font-semibold">{temp(day?.max)}</div>
|
||||
<div class="text-[11px] text-muted-foreground">{temp(day?.min)}</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -12,6 +12,8 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -81,16 +83,16 @@
|
||||
<aside
|
||||
class="absolute inset-y-0 right-0 flex w-80 max-w-[90vw] flex-col overflow-y-auto border-l border-border bg-card shadow-xl"
|
||||
transition:fly={{ x: 320, duration: 200, opacity: 1 }}
|
||||
aria-label="Variable selection"
|
||||
aria-label={m.variables_aria()}
|
||||
>
|
||||
<div
|
||||
class="sticky top-0 flex items-center justify-between border-b border-border bg-card px-5 py-4"
|
||||
>
|
||||
<h2 class="text-base font-bold">Variables</h2>
|
||||
<h2 class="text-base font-bold">{m.hourly_variables()}</h2>
|
||||
<button
|
||||
class="flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
onclick={onClose}
|
||||
aria-label="Close variable selection"
|
||||
aria-label={m.variables_close()}
|
||||
>
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
@@ -107,7 +109,7 @@
|
||||
<div class="flex flex-1 flex-col gap-6 px-5 py-4">
|
||||
<section>
|
||||
<h3 class="mb-2 text-[11px] font-bold tracking-wider text-primary uppercase">
|
||||
Hourly table
|
||||
{m.variables_table_section()}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each tableRowOrder as key, i (key)}
|
||||
@@ -129,7 +131,7 @@
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, -1)}
|
||||
disabled={i === 0}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} up"
|
||||
aria-label={m.variables_move_up({ variable: tableVariableLabels[key] ?? key })}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -145,7 +147,9 @@
|
||||
class="flex h-6 w-6 cursor-pointer items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
|
||||
onclick={() => moveRow(key, 1)}
|
||||
disabled={i === tableRowOrder.length - 1}
|
||||
aria-label="Move {tableVariableLabels[key] ?? key} down"
|
||||
aria-label={m.variables_move_down({
|
||||
variable: tableVariableLabels[key] ?? key
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
class="h-3.5 w-3.5"
|
||||
@@ -164,8 +168,9 @@
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Meteogram variables are configured with the <span class="font-semibold">Customize</span>
|
||||
button above the charts.
|
||||
{m.variables_charts_hint_before()}
|
||||
<span class="font-semibold">{m.meteograms_customize()}</span>
|
||||
{m.variables_charts_hint_after()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +179,7 @@
|
||||
class="cursor-pointer text-xs font-medium text-muted-foreground underline-offset-2 transition-colors hover:text-foreground hover:underline"
|
||||
onclick={resetDefaults}
|
||||
>
|
||||
Reset to defaults
|
||||
{m.action_reset_defaults()}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
Reference in New Issue
Block a user