initial cleanup
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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}¤t=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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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,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];
|
||||
|
||||
@@ -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,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();
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user