proper location handling and improve search interface

This commit is contained in:
terraputix
2026-02-16 01:22:27 +01:00
parent 852ae21a82
commit 73a9892ef5
21 changed files with 470 additions and 322 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"test:unit": "vitest", "test:unit": "vitest",
"test": "npm run test:unit -- --run", "test": "npm run test:unit -- --run",
"upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label select separator switch -y -o && prettier --write src/lib/components/ui" "upgrade:ui": "npx shadcn-svelte@latest add alert button card checkbox dialog input label popover select separator switch -y -o && prettier --write src/lib/components/ui"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.4.0", "@eslint/compat": "^1.4.0",
+163 -190
View File
@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte'; import { createEventDispatcher, onDestroy, tick } from 'svelte';
import { type GeoLocation } from '$lib/stores/settings'; import { type GeoLocation } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import * as Popover from '$lib/components/ui/popover';
export let label: string = 'Search location...'; export let label: string = 'Search location...';
export let placeholder: string = 'Enter city name...'; export let placeholder: string = 'Enter city name...';
@@ -18,26 +18,32 @@
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
let debounceTimeout: ReturnType<typeof setTimeout> | undefined; let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
let searchQuery = ''; let searchQuery = '';
let popoverOpen = false;
let searchInputEl: HTMLInputElement | null = null;
onDestroy(() => { onDestroy(() => {
clearInterval(debounceTimeout); clearTimeout(debounceTimeout);
}); });
let scrollY: number | undefined; const closePopover = () => {
popoverOpen = false;
const closeModal = () => {
dialogOpen = false;
if (scrollY) {
window.scrollTo({ top: scrollY, behavior: 'instant' });
}
}; };
const selectLocation = (location: GeoLocation) => { const selectLocation = (location: GeoLocation) => {
searchQuery = ''; searchQuery = '';
closeModal(); closePopover();
dispatch('location', location); dispatch('location', location);
}; };
async function focusInput() {
await tick();
searchInputEl?.focus();
}
$: if (popoverOpen) {
focusInput();
}
$: results = (async () => { $: results = (async () => {
if (debounceTimeout) { if (debounceTimeout) {
clearTimeout(debounceTimeout); clearTimeout(debounceTimeout);
@@ -91,17 +97,11 @@
return (await result.json()) as ResultSet; return (await result.json()) as ResultSet;
})(); })();
let dialogOpen = false;
</script> </script>
<Dialog.Root bind:open={dialogOpen}> <Popover.Root bind:open={popoverOpen}>
<Dialog.Trigger <Popover.Trigger
class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary" class="flex h-9 w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-3 text-[0.8125rem] text-muted-foreground transition-[border-color,box-shadow] duration-150 hover:border-primary"
onclick={(e) => {
e.preventDefault();
dialogOpen = !dialogOpen;
}}
> >
<svg <svg
class="h-3.5 w-3.5 shrink-0 opacity-50" class="h-3.5 w-3.5 shrink-0 opacity-50"
@@ -115,179 +115,152 @@
<path d="m21 21-4.3-4.3" /> <path d="m21 21-4.3-4.3" />
</svg> </svg>
<span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span> <span class="overflow-hidden text-ellipsis whitespace-nowrap">{label}</span>
</Dialog.Trigger> </Popover.Trigger>
<Dialog.Portal> <Popover.Content
<Dialog.Overlay class="dialog-overlay" /> class="popover-dropdown w-(--bits-popover-anchor-width) min-w-[320px] p-0"
side="bottom"
<Dialog.Content class="dialog-content"> align="start"
<Dialog.Header class="pb-4"> sideOffset={4}
<Dialog.Title class="text-lg font-semibold">Find Location</Dialog.Title> onOpenAutoFocus={(e) => {
<p class="text-sm text-muted-foreground">Search for a city or use GPS</p> e.preventDefault();
</Dialog.Header> focusInput();
}}
<div class="flex-1 overflow-hidden"> >
<div class="px-5"> <div class="flex flex-col">
<div class="mb-4 flex gap-2"> <div class="p-3">
<div class="flex-1"> <div class="flex gap-2">
<Input <div class="flex-1">
type="search" <Input
{placeholder} type="search"
class="h-10" {placeholder}
autocomplete="off" class="h-9"
spellcheck="false" autocomplete="off"
aria-label="Search Location" spellcheck="false"
bind:value={searchQuery} aria-label="Search Location"
/> bind:value={searchQuery}
</div> bind:ref={searchInputEl}
<Button />
variant="outline"
size="default"
class="px-3"
title="Use GPS Location"
onclick={() => (searchQuery = 'GPS')}
>
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</Button>
</div> </div>
</div> <Button
variant="outline"
<div class="flex-1 overflow-y-auto px-5 pb-5"> size="default"
{#await results} class="h-9 px-2.5"
<div class="flex h-24 items-center justify-center"> title="Use GPS Location"
<div class="flex items-center space-x-2"> onclick={() => (searchQuery = 'GPS')}
<div class="h-5 w-5 animate-spin rounded-full border-b-2 border-primary"></div> >
<span class="text-sm text-muted-foreground">Searching...</span> <svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div> <path
</div> stroke-linecap="round"
{:then results} stroke-linejoin="round"
{#if results.results && results.results.length === 0} stroke-width="2"
{#if searchQuery.length < 2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
<div />
class="flex items-start gap-2 rounded-md bg-primary/8 p-3 text-muted-foreground" <path
> stroke-linecap="round"
<svg stroke-linejoin="round"
class="h-4 w-4 shrink-0" stroke-width="2"
fill="none" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
stroke="currentColor" />
viewBox="0 0 24 24" </svg>
stroke-width="2" </Button>
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm">
Start typing to search or use GPS to detect your position
</span>
</div>
{:else}
<Alert.Root
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different term.
</Alert.Description>
</Alert.Root>
{/if}
{:else if !results.results}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{:else}
<div class="space-y-1">
{#each results.results || [] as location, i (i)}
<button
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-3 py-2.5 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
onclick={() => selectLocation(location)}
>
<div class="flex items-center gap-3">
<img
class="h-8 w-8 rounded-full"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
<div class="flex-1 text-left">
<div class="text-sm font-medium text-foreground">
{location.name}
</div>
<div class="text-xs text-muted-foreground">
{location.admin1 || ''}
{location.country || ''}
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
</div>
</div>
<svg
class="h-4 w-4 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
{/each}
</div>
{/if}
{:catch error}
<Alert.Root variant="destructive">
<Alert.Description>Error: {error.message}</Alert.Description>
</Alert.Root>
{/await}
</div> </div>
</div> </div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style> <div class="max-h-[min(400px,50vh)] overflow-y-auto px-3 pb-3">
:global(.dialog-overlay) { {#await results}
background: rgba(0, 0, 0, 0.2); <div class="flex h-20 items-center justify-center">
backdrop-filter: blur(2px); <div class="flex items-center space-x-2">
} <div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary"></div>
<span class="text-sm text-muted-foreground">Searching...</span>
:global(.dialog-content) { </div>
position: fixed; </div>
top: 10% !important; {:then results}
left: 50% !important; {#if results.results && results.results.length === 0}
transform: translateX(-50%) !important; {#if searchQuery.length < 2}
display: flex; <div
flex-direction: column; class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
max-height: calc(100vh - 10%); >
min-height: 400px; <svg
width: calc(100% - 2rem); class="mt-0.5 h-3.5 w-3.5 shrink-0"
max-width: 560px; fill="none"
overflow: hidden; stroke="currentColor"
border-radius: 0.75rem; viewBox="0 0 24 24"
border: 1px solid var(--border); stroke-width="2"
background: var(--card); >
box-shadow: <path
0 20px 25px -5px rgba(0, 0, 0, 0.1), stroke-linecap="round"
0 8px 10px -6px rgba(0, 0, 0, 0.1); stroke-linejoin="round"
padding-top: 1.25rem; d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
} />
</style> </svg>
<span class="text-xs">
Start typing to search or use GPS to detect your position
</span>
</div>
{:else}
<Alert.Root
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
>
<Alert.Description class="text-orange-700 dark:text-orange-300">
No locations found for "{searchQuery}". Try a different term.
</Alert.Description>
</Alert.Root>
{/if}
{:else if !results.results}
<Alert.Root variant="destructive">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{:else}
<div class="space-y-0.5">
{#each results.results || [] as location, i (i)}
<button
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
onclick={() => selectLocation(location)}
>
<div class="flex items-center gap-2.5">
<img
class="h-7 w-7 rounded-full"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
alt={location.country}
/>
<div class="flex-1 text-left">
<div class="text-sm font-medium text-foreground">
{location.name}
</div>
<div class="text-xs text-muted-foreground">
{location.admin1 || ''}
{location.country || ''}
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
</div>
</div>
<svg
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</button>
{/each}
</div>
{/if}
{:catch error}
<Alert.Root variant="destructive">
<Alert.Description>Error: {error.message}</Alert.Description>
</Alert.Root>
{/await}
</div>
</div>
</Popover.Content>
</Popover.Root>
+22 -2
View File
@@ -1,7 +1,13 @@
<script lang="ts"> <script lang="ts">
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import LocationSearch from '$lib/components/location/location-search.svelte'; import LocationSearch from '$lib/components/location/location-search.svelte';
@@ -16,6 +22,20 @@
storedLocation.subscribe((value) => { storedLocation.subscribe((value) => {
location = value; location = value;
}); });
function navigateToLocation(newLocation: GeoLocation) {
storedLocation.set(newLocation);
const locationRoute = buildLocationRoute(newLocation);
const currentPath = get(page).url.pathname;
if (currentPath.startsWith('/weather/compare')) {
goto(resolve('/weather/compare/[location]', { location: locationRoute }));
} else if (currentPath.startsWith('/weather/14-day')) {
goto(resolve('/weather/14-day/[location]', { location: locationRoute }));
} else {
goto(resolve('/weather/week/[location]', { location: locationRoute }));
}
}
</script> </script>
<header <header
@@ -60,7 +80,7 @@
<LocationSearch <LocationSearch
label="Search location..." label="Search location..."
on:location={(event) => { on:location={(event) => {
storedLocation.set(event.detail); navigateToLocation(event.detail);
}} }}
/> />
</div> </div>
+19
View File
@@ -0,0 +1,19 @@
import Close from './popover-close.svelte';
import Content from './popover-content.svelte';
import Portal from './popover-portal.svelte';
import Trigger from './popover-trigger.svelte';
import Root from './popover.svelte';
export {
Root,
Content,
Trigger,
Close,
Portal,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
Portal as PopoverPortal
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: PopoverPrimitive.CloseProps = $props();
</script>
<PopoverPrimitive.Close bind:ref data-slot="popover-close" {...restProps} />
@@ -0,0 +1,34 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { type WithoutChildrenOrChild, cn } from '$lib/utils/ui.js';
import PopoverPortal from './popover-portal.svelte';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof PopoverPortal>>;
} = $props();
</script>
<PopoverPortal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-end-2 data-[side=right]:slide-in-from-start-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--bits-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
className
)}
{...restProps}
/>
</PopoverPortal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ...restProps }: PopoverPrimitive.PortalProps = $props();
</script>
<PopoverPrimitive.Portal {...restProps} />
@@ -0,0 +1,18 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger
bind:ref
data-slot="popover-trigger"
class={cn('', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: PopoverPrimitive.RootProps = $props();
</script>
<PopoverPrimitive.Root bind:open {...restProps} />
+103
View File
@@ -0,0 +1,103 @@
import { error, redirect } from '@sveltejs/kit';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
export function buildLocationRoute(location: GeoLocation): string {
const locationRoute = geoLocationNameToRoute(location.name);
if (location.population && location.population > 543000) {
return locationRoute;
}
return locationRoute + '_' + location.id;
}
interface ResolveLocationOptions {
urlLocation: string;
routePrefix: string;
event: {
fetch: typeof fetch;
url: URL;
};
}
export async function resolveLocationFromRoute({
urlLocation,
routePrefix,
event
}: ResolveLocationOptions): Promise<GeoLocation> {
let location: GeoLocation;
if (urlLocation.includes('N') && urlLocation.includes('E')) {
const parts = urlLocation.split(/N|E/);
const latitude = parseFloat(parts[0]);
const longitude = parseFloat(parts[1]);
location = {
id: 0,
name: `${latitude}${longitude}`,
latitude,
longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
};
} else {
let urlLocationName: string;
let urlLocationId: string | undefined;
if (urlLocation.includes('_')) {
const split = urlLocation.split('_');
urlLocationName = split[0];
urlLocationId = split[1];
} else if (/^\d+$/.test(urlLocation)) {
urlLocationName = '';
urlLocationId = urlLocation;
} else {
urlLocationName = urlLocation;
urlLocationId = undefined;
}
if (urlLocationId) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
);
location = await res.json();
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
);
const geocodingResponse = await res.json();
if (geocodingResponse.results) {
location = geocodingResponse.results[0];
} else {
error(404, 'Location not found');
}
}
const locationRoute = geoLocationNameToRoute(location.name);
const canonicalSuffix =
location.population && location.population > 543000
? locationRoute
: locationRoute + '_' + location.id;
const canonicalPath = `${routePrefix}${canonicalSuffix}`;
if (event.url.pathname !== canonicalPath) {
throw redirect(303, canonicalPath);
}
}
storedLocation.set(location);
return location;
}
+1 -2
View File
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => { export const load: LayoutLoad = async () => {
const location = get(storedLocation);
return { return {
title: `Weather ${location.name}`, title: `Weather ${location.name}`,
location: location location: location
+1 -2
View File
@@ -4,9 +4,8 @@ import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from './$types';
const location = get(storedLocation);
export const load: LayoutLoad = async () => { export const load: LayoutLoad = async () => {
const location = get(storedLocation);
return { return {
heroTitle: `14 Day Weather ${location.name}`, heroTitle: `14 Day Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country heroDescription: location.admin1 ?? '' + ' ' + location.country
+15
View File
@@ -0,0 +1,15 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = buildLocationRoute(location);
throw redirect(303, '/weather/14-day/' + locationRoute);
}) satisfies PageLoad;
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { import {
buildAverageSeries, buildAverageSeries,
@@ -24,7 +23,7 @@
fetchEnsembleForecast fetchEnsembleForecast
} from '$lib/services/weather'; } from '$lib/services/weather';
import { defaultParameters } from '../options'; import { defaultParameters } from '../../options';
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
@@ -40,11 +39,12 @@
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
const location = get(storedLocation); let location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let params = $state({ let params = $state({
latitude: [52.52],
longitude: [13.41],
...defaultParameters, ...defaultParameters,
hourly: ['temperature_2m'], hourly: ['temperature_2m'],
models: ['gfs_seamless'] models: ['gfs_seamless']
@@ -87,14 +87,16 @@
if (!mounted || !hourlyVars?.length || !modelList?.length) return; if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
const loadData = async () => { const loadData = async () => {
loading = true; loading = true;
chartInstances = []; chartInstances = [];
chartComponents = []; chartComponents = [];
const result: EnsembleForecastResult = await fetchEnsembleForecast({ const result: EnsembleForecastResult = await fetchEnsembleForecast({
latitude: location.latitude!, latitude: loc.latitude!,
longitude: location.longitude!, longitude: loc.longitude!,
hourlyVariables: hourlyVars, hourlyVariables: hourlyVars,
models: modelList, models: modelList,
forecast_days: 14, forecast_days: 14,
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/14-day/',
event
});
return { location };
};
-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: `Model Compare ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+15
View File
@@ -0,0 +1,15 @@
import { get } from 'svelte/store';
import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings';
import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load = (async () => {
const location = get(storedLocation);
const locationRoute = buildLocationRoute(location);
throw redirect(303, '/weather/compare/' + locationRoute);
}) satisfies PageLoad;
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings'; import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { import {
buildAverageSeries, buildAverageSeries,
@@ -28,8 +27,8 @@
fetchModelComparison fetchModelComparison
} from '$lib/services/weather'; } from '$lib/services/weather';
import { hourly, models as modelsFlat } from '../options'; import { hourly, models as modelsFlat } from '../../options';
import { defaultParameters } from '../options'; import { defaultParameters } from '../../options';
import type * as echarts from 'echarts'; import type * as echarts from 'echarts';
@@ -47,11 +46,12 @@
let mounted = $state(false); let mounted = $state(false);
let loading = $state(true); let loading = $state(true);
const location = get(storedLocation); let location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let params = $state({ let params = $state({
latitude: [52.52],
longitude: [13.41],
...defaultParameters, ...defaultParameters,
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'], hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
models: [ models: [
@@ -101,14 +101,16 @@
if (!mounted || !hourlyVars?.length || !modelList?.length) return; if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
const loadData = async () => { const loadData = async () => {
loading = true; loading = true;
chartInstances = []; chartInstances = [];
chartComponents = []; chartComponents = [];
const result: ModelCompareResult = await fetchModelComparison({ const result: ModelCompareResult = await fetchModelComparison({
latitude: location.latitude!, latitude: loc.latitude!,
longitude: location.longitude!, longitude: loc.longitude!,
hourlyVariables: hourlyVars, hourlyVariables: hourlyVars,
models: modelList, models: modelList,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit', temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
@@ -0,0 +1,13 @@
import { resolveLocationFromRoute } from '$lib/utils/location';
import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => {
const location = await resolveLocationFromRoute({
urlLocation: event.params.location,
routePrefix: '/weather/compare/',
event
});
return { location };
};
+3 -11
View File
@@ -4,20 +4,12 @@ import { redirect } from '@sveltejs/kit';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo'; import { buildLocationRoute } from '$lib/utils/location';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load = (async () => { export const load = (async () => {
const location = get(storedLocation); const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = buildLocationRoute(location);
throw redirect( throw redirect(303, '/weather/week/' + locationRoute);
303,
'/weather/week/' +
(location.population
? location.population > 543000
? locationRoute
: locationRoute + '_' + location.id
: locationRoute + '_' + location.id)
);
}) satisfies PageLoad; }) satisfies PageLoad;
+7 -83
View File
@@ -1,89 +1,13 @@
import { error, redirect } from '@sveltejs/kit'; import { resolveLocationFromRoute } from '$lib/utils/location';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { geoLocationNameToRoute } from '$lib/utils/meteo';
import type { PageLoad } from './$types'; import type { PageLoad } from './$types';
export const load: PageLoad = async (event) => { export const load: PageLoad = async (event) => {
const urlLocation = event.params.location; const location = await resolveLocationFromRoute({
let urlLocationSplit, urlLocationName, urlLocationId; urlLocation: event.params.location,
routePrefix: '/weather/week/',
event
});
if (urlLocation.includes('_')) { return { location };
urlLocationSplit = urlLocation.split('_');
urlLocationName = urlLocationSplit[0];
urlLocationId = urlLocationSplit[1];
} else if (/^\d+$/.test(urlLocation)) {
// only numbers in location, must be geonames id
urlLocationName = '';
urlLocationId = urlLocation;
} else if (/^[a-zA-Z]/.test(urlLocation)) {
// only letters in location, must be geonames query
urlLocationName = urlLocation;
urlLocationId = undefined;
}
let location: GeoLocation;
// lat, long coordinates
if (urlLocation.includes('N') && urlLocation.includes('E')) {
urlLocationSplit = urlLocation.split(/N|E/);
const latitude = parseFloat(urlLocationSplit[0]);
const longitude = parseFloat(urlLocationSplit[1]);
location = {
id: 0,
name: `${latitude}${longitude}`,
latitude: latitude,
longitude: longitude,
elevation: 0,
feature_code: 'COORD',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: 'UTC',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
};
} else {
if (urlLocationId) {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/get?id=${urlLocationId}`
);
location = await res.json();
} else {
const res = await event.fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${urlLocationName}&count=1&language=en&format=json`
);
const geocodingResponse = await res.json();
if (geocodingResponse.results) {
location = geocodingResponse.results[0];
} else {
error(404, 'Location not found');
}
}
const locationRoute = geoLocationNameToRoute(location.name);
if (location.population && location.population > 543000) {
// 1000 biggest cities
if (event.url.pathname !== `/weather/week/${locationRoute}`) {
throw redirect(303, `/weather/week/${locationRoute}`);
}
} else {
if (event.url.pathname !== `/weather/week/${locationRoute + '_' + location.id}`) {
throw redirect(303, `/weather/week/${locationRoute + '_' + location.id}`);
}
}
}
storedLocation.set(location);
return { location: location };
}; };