initial cleanup

This commit is contained in:
terraputix
2026-01-07 23:45:10 +01:00
parent 2d31df88f0
commit f781ef4fd6
325 changed files with 1903 additions and 1000 deletions
+9 -2
View File
@@ -5,5 +5,12 @@
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<svelte:head>
<link rel="icon" href={favicon} />
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</svelte:head>
<main class="min-h-screen">
{@render children()}
</main>
+266 -6
View File
@@ -1,8 +1,268 @@
<script>
import { base } from '$app/paths';
<script lang="ts">
import { onMount } from 'svelte';
import { fade, fly } from 'svelte/transition';
import { Button } from '$lib/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '$lib/components/ui/card';
import { storedLocation } from '$lib/stores/settings';
import { get } from 'svelte/store';
let mounted = $state(false);
let location = get(storedLocation);
onMount(() => {
mounted = true;
});
const features = [
{
title: 'Week Prediction',
description:
'Get detailed hourly weather forecasts for the next 7 days with interactive charts and temperature gradients.',
href: `/weather`,
icon: 'calendar',
gradient: 'from-blue-500 to-cyan-500'
},
{
title: 'Model Comparison',
description:
'Compare multiple weather models side-by-side to understand forecast uncertainty and accuracy.',
href: '/weather/compare',
icon: 'trending-up',
gradient: 'from-purple-500 to-pink-500'
},
{
title: '14 Day Weather',
description:
'Extended forecast with ensemble model spreads showing temperature ranges and uncertainty.',
href: '/weather/14-day',
icon: 'cloud',
gradient: 'from-green-500 to-blue-500'
}
];
</script>
<h1>Open-Meteo Weather</h1>
<p>
<a href="{base}/weather">Weather Forecast</a>
</p>
<svelte:head>
<title>Open-Meteo Weather - Advanced Weather Forecasting</title>
<meta
name="description"
content="Professional weather forecasting with multiple models, extended forecasts, and detailed comparisons. Powered by Open-Meteo API."
/>
</svelte:head>
<div
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
>
<!-- Hero Section -->
<div class="container mx-auto px-6 pt-20 pb-16">
{#if mounted}
<div class="mb-16 text-center" in:fade={{ duration: 800, delay: 200 }}>
<h1
class="mb-6 bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600 bg-clip-text text-5xl font-bold text-transparent md:text-7xl"
>
Open-Meteo Weather
</h1>
<p class="mx-auto mb-8 max-w-3xl text-xl text-gray-600 md:text-2xl dark:text-gray-300">
Professional weather forecasting with advanced models, detailed comparisons, and extended
predictions
</p>
<!-- Quick Access Button -->
<div in:fly={{ y: 20, duration: 600, delay: 600 }}>
<Button
href="/weather"
size="lg"
class="bg-gradient-to-r from-blue-600 to-purple-600 px-8 py-3 text-lg text-white shadow-lg transition-all duration-300 hover:from-blue-700 hover:to-purple-700 hover:shadow-xl"
>
View Current Weather
<svg class="ml-2 h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
</Button>
</div>
</div>
{/if}
<!-- Features Grid -->
<div class="mb-20 grid gap-8 md:grid-cols-3">
{#each features as feature, index (feature.title)}
{#if mounted}
<div in:fly={{ y: 30, duration: 600, delay: 300 + index * 150 }}>
<Card
class="h-full border-0 bg-white/80 shadow-lg backdrop-blur-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl dark:bg-gray-800/80"
>
<CardHeader>
<div
class="h-12 w-12 rounded-xl bg-gradient-to-r {feature.gradient} mb-4 flex items-center justify-center"
>
{#if feature.icon === 'calendar'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{:else if feature.icon === 'trending-up'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
{:else if feature.icon === 'cloud'}
<svg
class="h-6 w-6 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
{/if}
</div>
<CardTitle class="mb-2 text-xl">{feature.title}</CardTitle>
<CardDescription class="text-gray-600 dark:text-gray-300">
{feature.description}
</CardDescription>
</CardHeader>
<CardContent class="pt-0">
<Button
href={feature.href}
variant="outline"
class="w-full hover:bg-gradient-to-r hover:{feature.gradient} transition-all duration-300 hover:border-transparent hover:text-white"
>
Explore {feature.title}
</Button>
</CardContent>
</Card>
</div>
{/if}
{/each}
</div>
<!-- Current Weather for Selected Location -->
{#if mounted && location}
<div class="mx-auto max-w-2xl" in:fade={{ duration: 600, delay: 800 }}>
<Card
class="border-blue-200 bg-gradient-to-r from-blue-500/10 to-purple-500/10 dark:border-blue-700"
>
<CardHeader>
<CardTitle class="text-center text-2xl">
Current Location: {location.name}
</CardTitle>
<CardDescription class="text-center">
{location.country}{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}°
</CardDescription>
</CardHeader>
<CardContent class="text-center">
<Button href="/weather" variant="default" class="bg-blue-600 hover:bg-blue-700">
View Detailed Forecast
</Button>
</CardContent>
</Card>
</div>
{/if}
</div>
<!-- Features Overview Section -->
<div class="bg-white/50 py-20 dark:bg-gray-800/50">
<div class="container mx-auto px-6">
{#if mounted}
<div class="mb-16 text-center" in:fade={{ duration: 600, delay: 1000 }}>
<h2 class="mb-4 text-4xl font-bold text-gray-800 dark:text-white">
Why Choose Our Weather Service?
</h2>
<p class="mx-auto max-w-2xl text-xl text-gray-600 dark:text-gray-300">
Powered by Open-Meteo API with multiple weather models and advanced visualization
</p>
</div>
<div class="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{#each [{ title: 'Multiple Models', desc: 'Compare ECMWF, GFS, and more', icon: 'layers' }, { title: 'Extended Forecasts', desc: 'Up to 14 days ahead', icon: 'clock' }, { title: 'Interactive Charts', desc: 'Highcharts visualization', icon: 'bar-chart' }, { title: 'Real-time Data', desc: 'Always up-to-date', icon: 'refresh' }] as feature, index (feature.title)}
<div class="text-center" in:fly={{ y: 20, duration: 500, delay: 1200 + index * 100 }}>
<div
class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gradient-to-r from-blue-500 to-purple-500"
>
<svg
class="h-8 w-8 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{#if feature.icon === 'layers'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
{:else if feature.icon === 'clock'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
{:else if feature.icon === 'bar-chart'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
{:else if feature.icon === 'refresh'}
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
{/if}
</svg>
</div>
<h3 class="mb-2 text-lg font-semibold text-gray-800 dark:text-white">
{feature.title}
</h3>
<p class="text-gray-600 dark:text-gray-300">{feature.desc}</p>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
<style>
:global(.container) {
max-width: 1200px;
}
</style>
+188 -79
View File
@@ -1,15 +1,11 @@
<script lang="ts">
import { get } from 'svelte/store';
import { page } from '$app/state';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
import Button from '$lib/components/ui/button/button.svelte';
import { fly, fade } from 'svelte/transition';
import { onMount } from 'svelte';
import WeatherNav from '$lib/components/navigation/weather-nav.svelte';
import LocationSearch from '$lib/components/location/location-search.svelte';
import { storedLocation } from '$lib/stores/settings';
let location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
import { get } from 'svelte/store';
import { page } from '$app/stores';
interface Props {
children?: import('svelte').Snippet;
@@ -17,81 +13,194 @@
let { children }: Props = $props();
const links = [
{
title: 'Weather Forecast',
url: '/en/weather',
children: [
{
title: 'Week Prediction',
url:
'/en/weather/week/' +
(location.population
? location.population > 543000
? locationRoute
: locationRoute + '_' + location.id
: locationRoute + '_' + location.id)
},
{ title: 'Model Comparison', url: '/en/weather/compare' },
{ title: '14 Day Weather', url: '/en/weather/14-day' }
]
}
];
let location = $state(get(storedLocation));
let mounted = $state(false);
let currentWeather = $state(null);
let selectedPath = $derived.by(() => {
for (const link of links) {
if (link.children) {
for (const l of link.children) {
if (page.url.pathname.includes(l.url) || page.url.pathname.includes(l.url + '/')) {
return l;
}
}
}
if (page.url.pathname === link.url || page.url.pathname === link.url + '/') {
return link;
}
// Subscribe to location changes
storedLocation.subscribe((value) => {
location = value;
if (mounted) {
loadCurrentWeather();
}
return {};
});
let mobileNavOpened = $state(false);
onMount(() => {
mounted = true;
loadCurrentWeather();
});
const loadCurrentWeather = async () => {
if (!location?.latitude) return;
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,weather_code&forecast_days=1`
);
const data = await response.json();
currentWeather = data;
} catch (error) {
console.error('Failed to load current weather:', error);
}
};
const getWeatherIcon = (code) => {
const iconMap = {
0: '☀️',
1: '🌤️',
2: '⛅',
3: '☁️',
45: '🌫️',
48: '🌫️',
51: '🌦️',
53: '🌦️',
55: '🌦️',
61: '🌧️',
63: '🌧️',
65: '🌧️',
71: '🌨️',
73: '🌨️',
75: '❄️',
95: '⛈️'
};
return iconMap[code] || '☁️';
};
const getPageTitle = () => {
const path = $page.url.pathname;
if (path.includes('/compare')) return 'Model Comparison';
if (path.includes('/14-day')) return '14 Day Forecast';
if (path.includes('/week')) return 'Week Prediction';
return 'Weather Forecast';
};
</script>
<div class="mb-12 flex flex-col md:mb-24 md:flex-row">
<aside class="w-full md:w-1/6 md:max-w-[400px] md:min-w-[230px]">
<nav class="sticky top-0 flex flex-col p-6 pb-3 md:pr-3 md:pb-6">
<Button
variant="outline"
class="flex justify-start p-3 md:hidden"
onclick={() => {
mobileNavOpened = !mobileNavOpened;
}}
>
<svg
class="lucide lucide-chevrons-up-down mr-2"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m7 15 5 5 5-5" />
<path d="m7 9 5-5 5 5" />
</svg><b>{selectedPath.title}</b>
</Button>
<WeatherNav />
<ul
class={`list-unstyled overflow-hidden duration-500 ${mobileNavOpened ? 'mt-2 max-h-[968px] md:max-h-[unset]' : 'max-h-0 md:max-h-[unset] '}`}
></ul>
</nav>
</aside>
<div
class="lg:max-w-unset flex flex-1 flex-col p-6 pt-0 md:max-w-[calc(100%-230px)] md:pt-6 md:pl-3"
>
{@render children?.()}
<div
class="min-h-screen bg-gradient-to-br from-blue-50 via-white to-cyan-50 dark:from-gray-900 dark:via-gray-800 dark:to-blue-900"
>
<!-- Hero Header Section -->
<div class="relative overflow-hidden bg-gradient-to-r from-blue-600 via-purple-600 to-cyan-600">
<div class="absolute inset-0 bg-black/10"></div>
<div class="relative">
<div class="container mx-auto px-6 py-12">
{#if mounted}
<div class="flex flex-col items-center space-y-6" in:fade={{ duration: 800 }}>
<!-- Page Title -->
<div class="text-center" in:fly={{ y: -20, duration: 600, delay: 200 }}>
<h1 class="mb-2 text-3xl font-bold text-white md:text-4xl">
{getPageTitle()}
</h1>
</div>
<!-- Location Display & Search -->
<div class="w-full max-w-2xl" in:fly={{ y: 20, duration: 600, delay: 400 }}>
<div
class="rounded-2xl bg-white/95 p-6 shadow-2xl backdrop-blur-md dark:bg-gray-800/95"
>
<!-- Current Location Display -->
{#if location}
<div
class="flex flex-col items-center justify-between space-y-4 md:flex-row md:space-y-0 md:space-x-6"
>
<!-- Location Info -->
<div class="flex flex-1 items-center space-x-4">
<div class="flex-shrink-0">
<img
class="h-12 w-12 rounded-full shadow-lg"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
</div>
<div class="flex-1 text-left">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">
{location.name}
</h2>
<p class="text-gray-600 dark:text-gray-300">
{#if location.admin1}{location.admin1},
{/if}{location.country}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N, {location.longitude.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if}
</p>
</div>
</div>
<!-- Current Weather -->
{#if currentWeather}
<div class="flex items-center space-x-3" in:fade={{ delay: 800 }}>
<div class="text-center">
<div class="mb-1 text-3xl">
{getWeatherIcon(currentWeather.current.weather_code)}
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(currentWeather.current.temperature_2m)}°C
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Location Search -->
<div class="mt-6 border-t border-gray-200 pt-6 dark:border-gray-600">
<LocationSearch
label="🔍 Change location or search for a new city..."
on:location={(event) => {
storedLocation.set(event.detail);
}}
/>
</div>
</div>
</div>
<!-- Stats Row (if location has population) -->
{#if location?.population}
<div
class="flex justify-center space-x-8 text-white/90"
in:fly={{ y: 20, duration: 600, delay: 600 }}
>
<div class="text-center">
<div class="text-sm font-medium">Population</div>
<div class="text-lg font-bold">{location.population.toLocaleString()}</div>
</div>
{#if location.timezone}
<div class="text-center">
<div class="text-sm font-medium">Timezone</div>
<div class="text-lg font-bold">{location.timezone}</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Decorative elements -->
<div class="pointer-events-none absolute top-0 left-0 h-full w-full overflow-hidden">
<div class="absolute -top-4 -right-4 h-24 w-24 rounded-full bg-white/10"></div>
<div class="absolute top-1/3 -left-8 h-16 w-16 rounded-full bg-white/5"></div>
<div class="absolute bottom-8 left-1/4 h-12 w-12 rounded-full bg-white/10"></div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-6 py-8">
{#if mounted}
<div in:fade={{ duration: 600, delay: 400 }}>
{@render children?.()}
</div>
{/if}
</div>
</div>
<style>
:global(.container) {
max-width: 1200px;
}
</style>
+2 -8
View File
@@ -8,13 +8,7 @@ const location = get(storedLocation);
export const load: LayoutLoad = async () => {
return {
heroTitle: `Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country,
heroImage: '/images/backgrounds/partly_cloudy.webp',
heroHeight: 400,
heroPrimaryButtonPath: null,
heroPrimaryButtonText: null,
heroSecondaryButtonPath: null,
heroSecondaryButtonText: null
title: `Weather ${location.name}`,
location: location
};
};
-703
View File
@@ -1,703 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { fetchWeatherApi } from 'openmeteo';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import LocationSearch from '$lib/components/location/location-search.svelte';
import cloudCover from './canvas/cloud-cover';
import daylight from './canvas/daylight';
import precip from './canvas/precip';
import raster from './canvas/raster';
import tempGradient from './canvas/temp-gradient';
import { defaultParameters, models } from './options';
import { getColor } from './utils/colors';
import weatherCodes from './utils/weather-codes';
const params = urlHashStore({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
models: 'best_match',
...defaultParameters
});
let location = $state($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let diffTemp: number | undefined = $state();
let maxTemp: number | undefined = $state();
let weatherCodesHourly: Float32Array | null | undefined = $state();
let canvasElement: HTMLCanvasElement | null | undefined = $state();
const today = new Date();
let selectedDay = $state(new Date());
let selectedDayIndex = $state(1);
let entries = $state(0);
let weather = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [$params.models],
hourly: [
'precipitation',
'precipitation_probability',
'temperature_2m',
'weather_code',
'windspeed_10m',
'winddirection_10m',
'cloud_cover',
'relative_humidity_2m'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!;
const hourly = response.hourly()!;
weatherCodesHourly = hourly.variables(3)?.valuesArray();
let hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map(
(_, i) =>
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
);
const hourlyTemps = hourly.variables(2)?.valuesArray();
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
const hourlyPrecip = hourly.variables(0)?.valuesArray();
const indexes = [];
for (const [index, _] of hourlyTemps.entries()) {
indexes.push(index);
}
const maxX = 10000;
const maxY = 500;
const deltaX = 10000 / hourly.variables(0)?.valuesArray()?.length;
const ctx = canvasElement?.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, maxX, maxY);
const minTemp = Math.min(
...hourly
.variables(2)
?.valuesArray()
.filter((t) => !isNaN(t))
);
maxTemp = Math.max(
...hourly
.variables(2)
?.valuesArray()
.filter((t) => !isNaN(t))
);
diffTemp = maxTemp - minTemp;
const config: ConfigInterface = {
maxX: maxX,
maxY: maxY,
deltaX: deltaX,
minTemp: minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp
};
// create canvas
daylight(ctx, config, hourlyTime);
raster(ctx, config, hourlyTime, today, canvasElement);
tempGradient(ctx, config, hourlyTemps, $params.temperature_unit);
cloudCover(ctx, config, hourlyCloudCover, canvasElement);
precip(ctx, config, hourlyPrecip, canvasElement);
}
return {
entries: [
{
id: 0,
name: 'temperature_2m',
title: 'Temperature',
values: hourly
.variables(2)
?.valuesArray()
?.map((t) => t.toFixed(1))
},
{
id: 1,
name: 'precipitation',
title: 'Precipitation',
values: hourly
.variables(0)
?.valuesArray()
?.map((p) => p.toFixed(1))
},
{
id: 2,
name: 'precipitation_probability',
title: 'Precip Prob.',
values: hourly
.variables(1)
?.valuesArray()
?.map((p) => p.toFixed(0))
},
{
id: 3,
name: 'windspeed_10m',
title: 'Wind',
values: hourly
.variables(4)
?.valuesArray()
?.map((p) => p.toFixed(0))
},
{
id: 4,
name: 'relative_humidity_2m',
title: 'Rel. Hum.',
values: hourly
.variables(7)
?.valuesArray()
?.map((p) => p.toFixed(0))
}
],
entriesLength: hourly.variables(0)?.valuesArray()?.length,
hourlyTime: hourlyTime,
windDirections: hourly.variables(5)?.valuesArray(),
indexes: indexes
};
})(location)
);
let weatherDaily = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [$params.models],
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'windspeed_10m_max',
'windgusts_10m_max',
'winddirection_10m_dominant'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!;
return {
daily: {
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
(_, i) =>
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
),
weather_code: daily.variables(0)!,
temperature_2m_max: daily.variables(1)!,
temperature_2m_min: daily.variables(2)!,
sunrise: daily.variables(3)!,
sunset: daily.variables(4)!,
sunshine_duration: daily.variables(5)!,
precipitation_sum: daily.variables(6)!,
windspeed_10m_max: daily.variables(7)!,
windgusts_10m_max: daily.variables(8)!,
winddirection_10m_dominant: daily.variables(9)!
}
};
})(location)
);
let winddir = true;
entries = 6;
let scrollDiv: HTMLElement = $state();
let tableCells;
const switchDay = (date: Date, index: number) => {
selectedDay = date;
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110, behavior: 'smooth' });
break;
}
}
selectedDayIndex = index;
};
onMount(() => {
setTimeout(() => {
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110 });
break;
}
}
}, 150);
document.onkeydown = (e) => {
if (!scrollDiv === document.activeElement || !scrollDiv.contains(document.activeElement)) {
if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) {
let newDate = new Date();
newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate);
}
}
if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new Date();
newDate.setDate(selectedDay.getDate() + 1);
switchDay(newDate);
}
}
}
};
});
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models));
// let modelSelectedValue = $derived($params.models[0]);
//
</script>
<svelte:head>
<title>Weather | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/en/weather" />
<meta name="description" content="segseg" />
</svelte:head>
<div class="">
<div class="weather-content" style="min-height: 50vh">
<div
in:fade
out:fade
style="min-height: 256px"
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
>
{#await weatherDaily then wd}
{#each wd.daily.time as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()}
{#if !isNaN(wd.daily.temperature_2m_max.values(index).toFixed(1))}
<button
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
class="cursor-pointer"
onclick={() => {
switchDay(time, index);
}}
>
<div
class="gap-md-1 flex flex-row items-center justify-center rounded-xl p-1 md:flex-col md:justify-center md:p-3 {selected
? 'bg-accent'
: ''}"
>
<div class="weather-week-date">
<b>{time.getDate()} - {time.getMonth() + 1}</b>
</div>
<div
data-text={time.toLocaleDateString('en-GB', { weekday: 'long' })}
class="grow-text relative mx-auto inline-flex flex-col {selected
? 'font-bold'
: ''}"
>
{time.toLocaleDateString('en-GB', { weekday: 'long' })}
</div>
<div class="weather-week-icon pe-none py-2">
<svg class="fill-foreground" width="60px" height="60px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wd.daily.weather_code.values(index)
]}.svg#Layer_1"
></use>
</svg>
</div>
<div
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
style={`background-color: ${getColor(wd.daily.temperature_2m_max.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_max.values(index).toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
style={`background: ${getColor(wd.daily.temperature_2m_min.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_min.values(index).toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div class="mt-2 flex items-center justify-center gap-1">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="26px" height="26px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"
></use>
</svg>
</div>
</div>
{Number(wd.daily.sunshine_duration.values(index) / 3600).toFixed(0)}h
</div>
<div class="mt-1 flex items-center justify-center">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="28px" height="28px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"
></use>
</svg>
</div>
</div>
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
1
)}{$params.precipitation_unit === 'mm' ? 'mm' : "'"}
</div>
</div>
</button>
{/if}
{/each}
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
</div>
<div class="ml-22 md:ml-0">
<h3 class="text-xl font-bold">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
<small>
{selectedDay.getDate() === new Date(today.getTime() - 24 * 60 * 60 * 1000).getDate()
? ' (Yesterday)'
: ''}
{selectedDay.getDate() === today.getDate() ? ' (Today)' : ''}
{selectedDay.getDate() === new Date(today.getTime() + 24 * 60 * 60 * 1000).getDate()
? ' (Tomorrow)'
: ''}
</small>
</h3>
</div>
<div
bind:this={scrollDiv}
style=" height: {218 + entries * 27.5}px; "
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
>
<canvas
bind:this={canvasElement}
id="weather_week_canvas"
class="border border-border"
style="margin-top: 24px; margin-left: 110px; width: 5000px; height: 200px; "
height="500px"
width="10000px"
></canvas>
<table in:fade class="absolute bottom-0 border-b border-border">
<caption style="display:none"> Weather Week {location.name} </caption>
<tbody>
{#await weather then weather}
<tr>
<th
scope="row"
class="time"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Time</th
>
{#each weather.indexes as index, j (j)}
<td
class="time {weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
data-date={weather.hourlyTime[index].getDate()}
data-time={weather.hourlyTime[index].getHours() + ':00'}
style="font-size: 11px; position: absolute; bottom: {188 +
27 * entries}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
>{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[
index
].getHours()}</td
>
{/each}
</tr>
<!-- icons -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Icons</th
>
{#each weather.indexes as index, j (j)}
{@const now =
weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()}
<td
style="position: absolute; bottom: {27.5 * entries -
24 +
0.8 * 200 -
0.54 *
200 *
((maxTemp - weather.entries[0].values[index]) / diffTemp)}px; left:{116 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
6 && weather.hourlyTime[index].getHours() < 21
? 'day'
: 'night'}-{weatherCodes[weatherCodesHourly[index]]}.svg#Layer_1"
></use>
</svg></td
>
{/each}
</tr>
<!-- min / max -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Temp graph</th
>
{#each weather.indexes as index, j (j)}
{@const temp = weather.entries[0].values[index]}
{#if !isNaN(temp)}
<td
class={weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}
style="position: absolute; bottom: {27.5 * entries -
49 +
0.8 * 200 -
0.55 * 200 * ((maxTemp - temp) / diffTemp)}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
>{temp.toFixed(0)}</td
>
{/if}
{/each}
</tr>
{#each weather.entries as entry, i (i)}
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>{entry.title}</th
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(entry.values[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="min-width: {5000 / weather.entriesLength}px; max-width: {5000 /
weather.entriesLength}px;
{entry.name === 'temperature_2m'
? 'background: ' +
getColor(
weather.entries[0].values[index].toFixed(0),
$params.temperature_unit
)
: ''};
{entry.name === 'temperature_2m'
? 'color: ' +
(weather.entries[0].values[index] <
($params.temperature_unit === 'celsius' ? -13 : 7) ||
weather.entries[0].values[index] >=
($params.temperature_unit === 'celsius' ? 40 : 104)
? 'white'
: 'black')
: ''};
{entry.name === 'precipitation_probability'
? 'background: rgba(0, 0, 230,' +
weather.entries[2].values[index] / 120 +
')'
: ''};
{entry.name === 'precipitation_probability'
? 'color: ' +
(weather.entries[2].values[index] > 50
? 'white'
: 'hsl(var(--foreground)')
: ''};
{entry.name === 'relative_humidity_2m'
? 'background: rgba(0, 240, 240,' +
weather.entries[4].values[index] ** 3.8 / 10 ** 8.2 +
')'
: ''};"
>{entry.name === 'precipitation' || entry.name === 'temperature_2m'
? entry.values[index].toFixed(1)
: entry.values[index]}</td
>
{/if}
{/each}
</tr>
{/each}
{#if winddir}
<!-- winddir -->
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="z-index: 20; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Wind Dir.</th
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(weather.windDirections[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="transform: rotate({weather.windDirections[
index
]}deg);min-width: {5000 / weather.entriesLength}px; max-width: {5000 /
weather.entriesLength}px;"
><svg class="fill-foreground" width="25px" height="25px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg></td
>
{/if}
{/each}
</tr>
{/if}
{/await}
</tbody>
</table>
</div>
</div>
{#await weatherDaily then wd}
{@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
{@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
<div class="mt-6">
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
</svg>Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}
</div>
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
</svg>
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
</div>
</div>
{/await}
<div>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">
<Select.Root name="model_selection" type="single" bind:value={$params.models}>
<Select.Trigger
aria-label="Forecast days input"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
>
<Select.Content preventScroll={false} class="border-border">
{#each models as mo}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
{/each}
</Select.Content>
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
>Weather model</Label
>
</Select.Root>
</div>
<div class="relative w-1/2">
<LocationSearch
style="height: 40px"
on:location={(event) => storedLocation.set(event.detail)}
label="Search Location"
/>
</div>
</div>
</div>
<div class="mt-6 mb-6">
<h2 class="text-2xl md:text-3xl">Color scale example</h2>
<div class="mt-3 grid grid-cols-4 md:mt-6">
{#if $params.temperature_unit == 'celsius'}
{#each [...Array(101).keys()].map((i) => -40 + i) as temp}
<div
class="weather-temp-max flex min-w-[70px] justify-center rounded p-1"
style={`background-color: ${getColor(temp, $params.temperature_unit)}; color: ${temp <= ($params.temperature_unit === 'celsius' ? 4 : 7) || temp >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{temp} °C <br />
{getColor(temp, $params.temperature_unit)}
</div>
{/each}
{:else}
{#each [...Array(91).keys()].map((i) => -40 + i * 2) as temp}
<div
class="weather-temp-max flex min-w-[70px] justify-center rounded p-1"
style={`background-color: ${getColor(temp, $params.temperature_unit)}; color: ${temp <= ($params.temperature_unit === 'celsius' ? 4 : 7) || temp >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{temp}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}<br />
{getColor(temp, $params.temperature_unit)}
</div>
{/each}
{/if}
</div>
</div>
</div>
<style>
.now {
font-weight: bold;
}
td {
text-align: center;
font-size: 13px;
}
.weather-week-icon {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #0061a5;
margin: 5px 0;
border-radius: 5px;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { PageLoad } from '$types';
export const prerender = true;
export const load = (async () => {
throw redirect(303, '/weather/week/');
}) satisfies PageLoad;
-12
View File
@@ -11,8 +11,6 @@
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import LocationSearch from '$lib/components/location/location-search.svelte';
import '../compare/highcharts.css';
import { defaultParameters } from './options';
@@ -295,16 +293,6 @@
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="relative w-1/4">
<LocationSearch
style="height: 40px"
on:location={(event) => {
storedLocation.set(event.detail);
window.location.reload();
}}
label="Search Location"
/>
</div>
<div class="flex gap-2">
<Switch
id="show_legend"
+2 -2
View File
@@ -2,7 +2,7 @@ export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement | null
canvasElement: HTMLCanvasElement
): void => {
if (ctx && series) {
ctx.beginPath();
@@ -41,7 +41,7 @@ export default (
);
// same series but reversed
for (const [ind, v] of series.entries()) {
for (const [ind, _v] of series.entries()) {
const index = series.length - 1 - ind;
const value = series[index];
const nextValue = series[index - 1];
+1 -1
View File
@@ -2,7 +2,7 @@ export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement | null
canvasElement: HTMLCanvasElement
): void => {
if (ctx && series) {
for (const [index, value] of series.entries()) {
+3 -3
View File
@@ -3,10 +3,10 @@ export default (
config: ConfigInterface,
series: Date[],
today: Date,
canvasElement: HTMLCanvasElement | null
canvasElement: HTMLCanvasElement
): void => {
if (ctx && series) {
for (const [index, _] of series.entries()) {
for (const [index, _v] of series.entries()) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX, 0);
ctx.lineTo(index * config.deltaX, config.maxY);
@@ -24,7 +24,7 @@ export default (
ctx.beginPath();
ctx.strokeStyle = 'red';
ctx.lineWidth = 5;
let minutes = today.getMinutes();
const minutes = today.getMinutes();
ctx.moveTo(index * config.deltaX + (config.deltaX / 60) * minutes, 0);
ctx.lineTo(index * config.deltaX + (config.deltaX / 60) * minutes, config.maxY);
ctx.stroke();
+1 -1
View File
@@ -18,7 +18,7 @@ export default (
0,
0.25 * config.maxY + ((config.maxTemp - series[0]) / config.diffTemp) * 0.55 * config.maxY
);
for (const [index, value] of series?.filter((t) => !isNaN(t)).entries()) {
for (const [index, value] of series.filter((t) => !isNaN(t)).entries()) {
const indexDiffTemp = config.maxTemp - value;
const indexDiffTempNext = config.maxTemp - series[index + 1];
+1 -15
View File
@@ -12,8 +12,6 @@
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import LocationSearch from '$lib/components/location/location-search.svelte';
import { hourly, models } from '../options';
import './highcharts.css';
import { defaultParameters } from './options';
@@ -102,14 +100,13 @@
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(0);
let variableCount = 0;
for (let [model, values] of Object.entries(data.hourly)) {
if (model === 'time') {
continue;
}
if (model.startsWith(variable)) {
for (let [index, val] of values.entries()) {
if (!!val) {
if (val) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
@@ -133,7 +130,6 @@
pointInterval: pointInterval
});
}
variableCount++;
}
}
@@ -295,16 +291,6 @@
<div class="">
<div class="mt-6 flex flex-col items-center gap-6 md:mt-12 md:flex-row">
<div class="relative w-1/4">
<LocationSearch
style="height: 40px"
on:location={(event) => {
storedLocation.set(event.detail);
window.location.reload();
}}
label="Search Location"
/>
</div>
<div class="flex gap-2">
<Switch
id="show_legend"
+5 -5
View File
@@ -13,9 +13,9 @@ export function rgbToHex(rgb: string) {
b,
hex = '';
if ((result = rgbRegex.exec(rgb))) {
r = componentFromStr(result[1], result[2]);
g = componentFromStr(result[3], result[4]);
b = componentFromStr(result[5], result[6]);
r = componentFromStr(result[1], Number(result[2]));
g = componentFromStr(result[3], Number(result[4]));
b = componentFromStr(result[5], Number(result[6]));
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
@@ -25,9 +25,9 @@ export function rgbToHex(rgb: string) {
return hex;
}
export const getColor = (temp: number, unit = 'celsius'): string => {
export const getColor = (tempString: string, unit = 'celsius'): string => {
let index = 0;
temp = Number(temp);
const temp = Number(tempString);
if (unit === 'celsius') {
if (temp <= -40) {
index = 0;
-14
View File
@@ -1,14 +0,0 @@
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => {
return {
heroTitle: `Weather Week ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+2 -6
View File
@@ -1,21 +1,17 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from '$types';
export const prerender = true;
export const load = (async (event) => {
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
throw redirect(
303,
'/en/weather/week/' +
'/weather/week/' +
(location.population
? location.population > 543000
? locationRoute
+657 -5
View File
@@ -1,9 +1,661 @@
<script lang="ts">
let { data } = $props();
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
const location = data.location;
import { fetchWeatherApi } from 'openmeteo';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
import cloudCover from '../../canvas/cloud-cover';
import daylight from '../../canvas/daylight';
import precip from '../../canvas/precip';
import raster from '../../canvas/raster';
import tempGradient from '../../canvas/temp-gradient';
import { defaultParameters, models } from '../../options';
import { getColor } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import { SvelteDate } from 'svelte/reactivity';
import { pad } from '$lib/utils';
const params = urlHashStore({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
models: 'best_match',
...defaultParameters
});
let location = $state($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let diffTemp: number | undefined = $state();
let maxTemp: number | undefined = $state();
let weatherCodesHourly: Float32Array | null | undefined = $state();
let canvasElement: HTMLCanvasElement | null | undefined = $state();
const today = new Date();
let selectedDay = $state(new Date());
let selectedDayIndex = $state(1);
let entries = $state(0);
let weather = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [$params.models],
hourly: [
'precipitation',
'precipitation_probability',
'temperature_2m',
'weather_code',
'windspeed_10m',
'winddirection_10m',
'cloud_cover',
'relative_humidity_2m'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const hourly = response.hourly()!;
weatherCodesHourly = hourly.variables(3)?.valuesArray();
let hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map(
(_, i) =>
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
);
const hourlyTemps = hourly.variables(2)?.valuesArray();
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
const hourlyPrecip = hourly.variables(0)?.valuesArray();
const indexes = [];
if (hourlyTemps) {
for (const index of hourlyTemps.keys()) {
indexes.push(index);
}
}
const maxX = 10000;
const maxY = 500;
const deltaX = 10000 / hourly.variables(0)?.valuesArray()?.length;
const ctx = canvasElement?.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, maxX, maxY);
const minTemp = Math.min(
...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t))
);
maxTemp = Math.max(...(hourly.variables(2)?.valuesArray() ?? []).filter((t) => !isNaN(t)));
diffTemp = maxTemp - minTemp;
const config: ConfigInterface = {
maxX: maxX,
maxY: maxY,
deltaX: deltaX,
minTemp: minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp
};
// create canvas
daylight(ctx, config, hourlyTime);
raster(ctx, config, hourlyTime, today, canvasElement);
tempGradient(ctx, config, hourlyTemps, $params.temperature_unit);
cloudCover(ctx, config, hourlyCloudCover, canvasElement);
precip(ctx, config, hourlyPrecip, canvasElement);
}
return {
entries: [
{
id: 0,
name: 'temperature_2m',
title: 'Temperature',
values: hourly
.variables(2)
?.valuesArray()
?.map((t) => t.toFixed(1))
},
{
id: 1,
name: 'precipitation',
title: 'Precipitation',
values: hourly
.variables(0)
?.valuesArray()
?.map((p) => p.toFixed(1))
},
{
id: 2,
name: 'precipitation_probability',
title: 'Precip Prob.',
values: hourly
.variables(1)
?.valuesArray()
?.map((p) => p.toFixed(0))
},
{
id: 3,
name: 'windspeed_10m',
title: 'Wind',
values: hourly
.variables(4)
?.valuesArray()
?.map((p) => p.toFixed(0))
},
{
id: 4,
name: 'relative_humidity_2m',
title: 'Rel. Hum.',
values: hourly
.variables(7)
?.valuesArray()
?.map((p) => p.toFixed(0))
}
],
entriesLength: hourly.variables(0)?.valuesArray()?.length,
hourlyTime: hourlyTime,
windDirections: hourly.variables(5)?.valuesArray(),
indexes: indexes
};
})(location)
);
let weatherDaily = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [$params.models],
daily: [
'weather_code',
'temperature_2m_max',
'temperature_2m_min',
'sunrise',
'sunset',
'sunshine_duration',
'precipitation_sum',
'windspeed_10m_max',
'windgusts_10m_max',
'winddirection_10m_dominant'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!;
return {
daily: {
time: [...Array((Number(daily.timeEnd()) - Number(daily.time())) / daily.interval())].map(
(_, i) =>
new Date((Number(daily.time()) + i * daily.interval() + utcOffsetSeconds) * 1000)
),
weather_code: daily.variables(0)!,
temperature_2m_max: daily.variables(1)!,
temperature_2m_min: daily.variables(2)!,
sunrise: daily.variables(3)!,
sunset: daily.variables(4)!,
sunshine_duration: daily.variables(5)!,
precipitation_sum: daily.variables(6)!,
windspeed_10m_max: daily.variables(7)!,
windgusts_10m_max: daily.variables(8)!,
winddirection_10m_dominant: daily.variables(9)!
}
};
})(location)
);
let winddir = true;
entries = 6;
let scrollDiv: HTMLElement = $state();
let tableCells;
const switchDay = (date: SvelteDate, index: number) => {
selectedDay = date;
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110, behavior: 'smooth' });
break;
}
}
selectedDayIndex = index;
};
onMount(() => {
setTimeout(() => {
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110 });
break;
}
}
}, 150);
document.onkeydown = (e) => {
if (!scrollDiv === document.activeElement || !scrollDiv.contains(document.activeElement)) {
if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) {
let newDate = new SvelteDate();
newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate);
}
}
if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new SvelteDate();
newDate.setDate(selectedDay.getDate() + 1);
switchDay(newDate);
}
}
}
};
});
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models));
// let modelSelectedValue = $derived($params.models[0]);
//
</script>
<h1>
{location ? location.name : ''}, population: {location.population}
</h1>
<svelte:head>
<title>Weather | Open-Meteo.com</title>
<link rel="canonical" href="https://open-meteo.com/weather" />
<meta name="description" content="segseg" />
</svelte:head>
<div class="">
<div class="weather-content" style="min-height: 50vh">
<div
in:fade
out:fade
style="min-height: 256px"
class="weather-week gap-md-2 mb-4 flex flex-col md:flex-row"
>
{#await weatherDaily then wd}
{#each wd.daily.time as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()}
{#if !isNaN(wd.daily.temperature_2m_max.values(index).toFixed(1))}
<button
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
class="cursor-pointer"
onclick={() => {
switchDay(time, index);
}}
>
<div
class="gap-md-1 flex flex-row items-center justify-center rounded-xl p-1 md:flex-col md:justify-center md:p-3 {selected
? 'bg-accent'
: ''}"
>
<div class="weather-week-date">
<b>{time.getDate()} - {time.getMonth() + 1}</b>
</div>
<div
data-text={time.toLocaleDateString('en-GB', { weekday: 'long' })}
class="grow-text relative mx-auto inline-flex flex-col {selected
? 'font-bold'
: ''}"
>
{time.toLocaleDateString('en-GB', { weekday: 'long' })}
</div>
<div class="weather-week-icon pe-none py-2">
<svg class="fill-foreground" width="60px" height="60px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wd.daily.weather_code.values(index)
]}.svg#Layer_1"
></use>
</svg>
</div>
<div
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm"
style={`background-color: ${getColor(wd.daily.temperature_2m_max.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_max.values(index).toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm"
style={`background: ${getColor(wd.daily.temperature_2m_min.values(index).toFixed(0), $params.temperature_unit)}; color: ${wd.daily.temperature_2m_min.values(index) < ($params.temperature_unit === 'celsius' ? 4 : 7) || wd.daily.temperature_2m_min.values(index) >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_min.values(index).toFixed(1)}
{$params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div>
<div class="mt-2 flex items-center justify-center gap-1">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="26px" height="26px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-day-sunny.svg#Layer_1"
></use>
</svg>
</div>
</div>
{Number(wd.daily.sunshine_duration.values(index) / 3600).toFixed(0)}h
</div>
<div class="mt-1 flex items-center justify-center">
<div class="relative flex h-6 w-6 items-center justify-center">
<div class="absolute">
<svg class="fill-foreground" width="28px" height="28px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-raindrop.svg#Layer_1"
></use>
</svg>
</div>
</div>
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
1
)}{$params.precipitation_unit === 'mm' ? 'mm' : "'"}
</div>
</div>
</button>
{/if}
{/each}
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
</div>
<div class="ml-22 md:ml-0">
<h3 class="text-xl font-bold">
{selectedDay.toLocaleDateString('en-GB', { weekday: 'long' })}
<small>
{selectedDay.getDate() === new Date(today.getTime() - 24 * 60 * 60 * 1000).getDate()
? ' (Yesterday)'
: ''}
{selectedDay.getDate() === today.getDate() ? ' (Today)' : ''}
{selectedDay.getDate() === new Date(today.getTime() + 24 * 60 * 60 * 1000).getDate()
? ' (Tomorrow)'
: ''}
</small>
</h3>
</div>
<div
bind:this={scrollDiv}
style=" height: {218 + entries * 27.5}px; "
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-[110px]"
>
<canvas
bind:this={canvasElement}
id="weather_week_canvas"
class="border border-border"
style="margin-top: 24px; margin-left: 110px; width: 5000px; height: 200px; "
height="500px"
width="10000px"
></canvas>
<table in:fade class="absolute bottom-0 border-b border-border">
<caption style="display:none"> Weather Week {location.name} </caption>
<tbody>
{#await weather then weather}
<tr>
<th
scope="row"
class="time"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Time</th
>
{#each weather.indexes as index, j (j)}
<td
class="time {weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
data-date={weather.hourlyTime[index].getDate()}
data-time={weather.hourlyTime[index].getHours() + ':00'}
style="font-size: 11px; position: absolute; bottom: {188 +
27 * entries}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
>{weather.hourlyTime[index].getHours() < 10 ? '0' : ''}{weather.hourlyTime[
index
].getHours()}</td
>
{/each}
</tr>
<!-- icons -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Icons</th
>
{#each weather.indexes as index, j (j)}
{@const now =
weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()}
<td
style="position: absolute; bottom: {27.5 * entries -
24 +
0.8 * 200 -
0.54 *
200 *
((maxTemp - weather.entries[0].values[index]) / diffTemp)}px; left:{116 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
><svg class="fill-foreground {now ? 'scale-125' : ''}" width="20px" height="20px">
<use
class="stroke-2"
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
6 && weather.hourlyTime[index].getHours() < 21
? 'day'
: 'night'}-{weatherCodes[weatherCodesHourly[index]]}.svg#Layer_1"
></use>
</svg></td
>
{/each}
</tr>
<!-- min / max -->
<tr>
<th
scope="row"
style="color: transparent; padding-left: 4px; background: transparent; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Temp graph</th
>
{#each weather.indexes as index, j (j)}
{@const temp = weather.entries[0].values[index]}
{#if !isNaN(temp)}
<td
class={weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}
style="position: absolute; bottom: {27.5 * entries -
49 +
0.8 * 200 -
0.55 * 200 * ((maxTemp - temp) / diffTemp)}px; left:{111 +
(5000 / weather.entriesLength) * index}px; min-width: {5000 /
weather.entriesLength}px; max-width: {5000 / weather.entriesLength}px;"
>{temp.toFixed(0)}</td
>
{/if}
{/each}
</tr>
{#each weather.entries as entry, i (i)}
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>{entry.title}</th
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(entry.values[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="min-width: {5000 / weather.entriesLength}px; max-width: {5000 /
weather.entriesLength}px;
{entry.name === 'temperature_2m'
? 'background: ' +
getColor(
weather.entries[0].values[index].toFixed(0),
$params.temperature_unit
)
: ''};
{entry.name === 'temperature_2m'
? 'color: ' +
(weather.entries[0].values[index] <
($params.temperature_unit === 'celsius' ? -13 : 7) ||
weather.entries[0].values[index] >=
($params.temperature_unit === 'celsius' ? 40 : 104)
? 'white'
: 'black')
: ''};
{entry.name === 'precipitation_probability'
? 'background: rgba(0, 0, 230,' +
weather.entries[2].values[index] / 120 +
')'
: ''};
{entry.name === 'precipitation_probability'
? 'color: ' +
(weather.entries[2].values[index] > 50
? 'white'
: 'hsl(var(--foreground)')
: ''};
{entry.name === 'relative_humidity_2m'
? 'background: rgba(0, 240, 240,' +
weather.entries[4].values[index] ** 3.8 / 10 ** 8.2 +
')'
: ''};"
>{entry.name === 'precipitation' || entry.name === 'temperature_2m'
? entry.values[index].toFixed(1)
: entry.values[index]}</td
>
{/if}
{/each}
</tr>
{/each}
{#if winddir}
<!-- winddir -->
<tr class="border-t border-border">
<th
scope="row"
class="bg-background text-left"
style="z-index: 20; left: 0px; min-width: 110px; max-width: 110px; position: sticky;"
>Wind Dir.</th
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(weather.windDirections[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
? 'now'
: ''}"
style="transform: rotate({weather.windDirections[
index
]}deg);min-width: {5000 / weather.entriesLength}px; max-width: {5000 /
weather.entriesLength}px;"
><svg class="fill-foreground" width="25px" height="25px">
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
</svg></td
>
{/if}
{/each}
</tr>
{/if}
{/await}
</tbody>
</table>
</div>
</div>
{#await weatherDaily then wd}
{@const sunrise = new Date(Number(wd.daily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
{@const sunset = new Date(Number(wd.daily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
<div class="mt-6">
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunrise.svg#Layer_1"></use>
</svg>Sunrise: {pad(sunrise.getHours())}:{pad(sunrise.getMinutes())}
</div>
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
<use class="stroke-2" xlink:href="/images/weather-icons/wi-sunset.svg#Layer_1"></use>
</svg>
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
</div>
</div>
{/await}
<div>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">
<Select.Root name="model_selection" type="single" bind:value={$params.models}>
<Select.Trigger
aria-label="Forecast days input"
class="h-12 cursor-pointer pt-6 [&_svg]:mb-3">{modelSelected?.label}</Select.Trigger
>
<Select.Content preventScroll={false} class="border-border">
{#each models as mo (mo.value)}
<Select.Item class="cursor-pointer" value={mo.value}>{mo.label}</Select.Item>
{/each}
</Select.Content>
<Label class="absolute top-[0.35rem] left-2 z-10 px-1 text-xs text-muted-foreground"
>Weather model</Label
>
</Select.Root>
</div>
</div>
</div>
</div>
<style>
.now {
font-weight: bold;
}
td {
text-align: center;
font-size: 13px;
}
.weather-week-icon {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
background: #0061a5;
margin: 5px 0;
border-radius: 5px;
}
</style>
+4 -4
View File
@@ -62,12 +62,12 @@ export const load = (async (event) => {
if (location.population && location.population > 543000) {
// 1000 biggest cities
if (event.url.pathname !== `/en/weather/week/${locationRoute}`) {
throw redirect(303, `/en/weather/week/${locationRoute}`);
if (event.url.pathname !== `/weather/week/${locationRoute}`) {
throw redirect(303, `/weather/week/${locationRoute}`);
}
} else {
if (event.url.pathname !== `/en/weather/week/${locationRoute + '_' + location.id}`) {
throw redirect(303, `/en/weather/week/${locationRoute + '_' + location.id}`);
if (event.url.pathname !== `/weather/week/${locationRoute + '_' + location.id}`) {
throw redirect(303, `/weather/week/${locationRoute + '_' + location.id}`);
}
}
}