initial commit

This commit is contained in:
terraputix
2026-01-07 22:24:02 +01:00
commit 2d31df88f0
315 changed files with 14484 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,266 @@
<script lang="ts">
import { createEventDispatcher, onDestroy } from 'svelte';
import { type GeoLocation } from '$lib/stores/settings';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
export let label: string = 'Search Locations...';
interface ResultSet {
results: GeoLocation[] | undefined;
}
const dispatch = createEventDispatcher();
let debounceTimeout: ReturnType<typeof setTimeout> | undefined;
let searchQuery = '';
onDestroy(() => {
clearInterval(debounceTimeout);
});
let scrollY: number | undefined;
const closeModal = () => {
dialogOpen = false;
if (scrollY) {
window.scrollTo({ top: scrollY, behavior: 'instant' });
}
};
const selectLocation = (location: GeoLocation) => {
searchQuery = '';
closeModal();
dispatch('location', location);
};
$: results = (async () => {
if (debounceTimeout) {
clearTimeout(debounceTimeout);
}
if (searchQuery.length < 2) {
return { results: [] };
}
await new Promise((resolve) => {
debounceTimeout = setTimeout(resolve, 300);
});
if (searchQuery.toLowerCase() == 'gps') {
let position: GeolocationPosition = await new Promise((resolve, reject) =>
navigator.geolocation.getCurrentPosition(resolve, reject, {})
);
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;
return {
results: [
{
id: 100000000 + Math.floor(latitude * 100 + longitude + 1000),
name: `GPS ${latitude.toFixed(2)}°N ${longitude.toFixed(2)}°E`,
latitude: latitude,
longitude: longitude,
elevation: position.coords.altitude ?? NaN,
feature_code: '',
country_code: undefined,
admin1_id: undefined,
admin3_id: undefined,
admin4_id: undefined,
timezone: '',
population: undefined,
postcodes: undefined,
country_id: undefined,
country: undefined,
admin1: undefined,
admin3: undefined,
admin4: undefined
}
]
};
}
// Always set format=json to fetch data
const url = 'https://geocoding-api.open-meteo.com/v1/search';
const fetchUrl = `${url}?${new URLSearchParams({ name: searchQuery })}`;
const result = await fetch(fetchUrl);
if (!result.ok) {
throw new Error(await result.text());
}
return (await result.json()) as ResultSet;
})();
let dialogOpen = false;
</script>
<Dialog.Root bind:open={dialogOpen}>
<Dialog.Trigger
id="location_search"
onclick={(e) => {
e.preventDefault();
dialogOpen = !dialogOpen;
}}
class="flex h-12 w-full cursor-pointer items-center justify-center rounded-md border border-border px-5 pr-6 duration-200 hover:bg-accent"
><svg
class="lucide lucide-search mr-[5px]"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
{label}</Dialog.Trigger
>
<Dialog.Portal>
<Dialog.Overlay class="bg-black/5" />
<Dialog.Content
class="top-[10%] flex max-h-[calc(100vh-10%)] min-h-[400px] translate-y-0 flex-col overflow-y-auto border-border sm:max-w-[600px]"
>
<Dialog.Header>
<Dialog.Title>Search Locations</Dialog.Title>
</Dialog.Header>
<div>
<div class="flex gap-3">
<Input
type="search"
id="location_search_input"
autocomplete="off"
spellcheck="false"
aria-label="Search Location"
bind:value={searchQuery}
/>
<Button
id="location_search_gps"
variant="outline"
title="Detect Location via GPS"
onclick={() => (searchQuery = 'GPS')}
><svg
class="lucide lucide-mouse-pointer-2"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"
/>
</svg></Button
>
</div>
{#await results}
<div class="mt-4 flex h-full items-center justify-center">
<Alert.Root class="my-auto w-[unset] border-border">
<Alert.Description>Loading...</Alert.Description>
</Alert.Root>
</div>
{:then results}
{#if results.results && results.results.length === 0}
{#if searchQuery.length < 2}
<Alert.Root class="my-auto mt-4 w-[unset] border-border">
<Alert.Description>Start typing to search for locations</Alert.Description>
</Alert.Root>
{:else}
<Alert.Root class="my-auto !mt-4 w-[unset] border-border">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{/if}
{:else if !results.results}
<Alert.Root class="my-auto !mt-4 w-[unset] border-border">
<Alert.Description>No locations found</Alert.Description>
</Alert.Root>
{:else}
<div class="list-group mt-4">
<div id="location_search_results" class="rounded-lg border border-border">
{#each results.results || [] as location, i (i)}
<Button
variant="outline"
class="location-search-result flex h-[unset] w-full justify-between gap-0 rounded-none py-2 pr-1 pl-3 not-last:border-b md:pr-2 {i ===
0
? 'rounded-t-md'
: ''} {results.results && i === results.results.length - 1
? 'rounded-b-md'
: ''}"
onclick={() => selectLocation(location)}
>
<div class="pointer-events-none flex flex-col gap-1 truncate">
<div class="flex items-center gap-2 truncate text-lg">
<img
height="24"
width="24"
src="/images/country-flags/{(
location.country_code || 'united_nations'
).toLowerCase()}.svg"
title={location.country}
alt={location.country_code}
/>
{location.name}
</div>
<div class="truncate text-left text-sm text-muted-foreground">
{location.admin1 || ''} ({location.latitude.toFixed(2)}°N {location.longitude.toFixed(
2
)}°E{#if location.elevation}{' ' +
location.elevation.toFixed(0) +
'm asl'}{/if})
</div>
</div>
<div class="-mr-1 flex justify-self-end">
<Button
variant="ghost"
class="px-2 duration-200 hover:brightness-[140%] md:px-3"
href="https://www.openstreetmap.org/#map=13/{location.latitude}/{location.longitude}"
target="_blank"
title="Show on map"
onclick={(e) => {
e.stopPropagation();
}}
>
<svg
class="lucide lucide-map"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z"
/>
<path d="M15 5.764v15" />
<path d="M9 3.236v15" />
</svg>
</Button>
</div>
</Button>
{/each}
</div>
</div>
{/if}
{:catch error}
<Alert.Root variant="destructive" class="my-auto mt-4 w-[unset]">
<Alert.Description>{error.message}</Alert.Description>
</Alert.Root>
{/await}
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
@@ -0,0 +1,23 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-description"
class={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className
)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="alert-title"
class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
{...restProps}
>
{@render children?.()}
</div>
+44
View File
@@ -0,0 +1,44 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const alertVariants = tv({
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
}
},
defaultVariants: {
variant: 'default'
}
});
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
</script>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
variant = 'default',
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
variant?: AlertVariant;
} = $props();
</script>
<div
bind:this={ref}
data-slot="alert"
class={cn(alertVariants({ variant }), className)}
{...restProps}
role="alert"
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from './alert.svelte';
import Description from './alert-description.svelte';
import Title from './alert-title.svelte';
export { alertVariants, type AlertVariant } from './alert.svelte';
export {
Root,
Description,
Title,
//
Root as Alert,
Description as AlertDescription,
Title as AlertTitle
};
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs',
destructive:
'bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs',
outline:
'bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants
} from './button.svelte';
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant
};
@@ -0,0 +1,36 @@
<script lang="ts">
import { Checkbox as CheckboxPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
</script>
<CheckboxPrimitive.Root
bind:ref
data-slot="checkbox"
class={cn(
'peer flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary',
className
)}
bind:checked
bind:indeterminate
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<div data-slot="checkbox-indicator" class="text-current transition-none">
{#if checked}
<CheckIcon class="size-3.5" />
{:else if indeterminate}
<MinusIcon class="size-3.5" />
{/if}
</div>
{/snippet}
</CheckboxPrimitive.Root>
+6
View File
@@ -0,0 +1,6 @@
import Root from './checkbox.svelte';
export {
Root,
//
Root as Checkbox
};
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.CloseProps = $props();
</script>
<DialogPrimitive.Close bind:ref data-slot="dialog-close" {...restProps} />
@@ -0,0 +1,45 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import DialogPortal from './dialog-portal.svelte';
import XIcon from '@lucide/svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
showCloseButton = true,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof DialogPortal>>;
children: Snippet;
showCloseButton?: boolean;
} = $props();
</script>
<DialogPortal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
data-slot="dialog-content"
class={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
className
)}
{...restProps}
>
{@render children?.()}
{#if showCloseButton}
<DialogPrimitive.Close
class="absolute end-4 top-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
{/if}
</DialogPrimitive.Content>
</DialogPortal>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
data-slot="dialog-description"
class={cn('text-sm text-muted-foreground', className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
data-slot="dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-start', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
data-slot="dialog-overlay"
class={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
className
)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ...restProps }: DialogPrimitive.PortalProps = $props();
</script>
<DialogPrimitive.Portal {...restProps} />
@@ -0,0 +1,17 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
data-slot="dialog-title"
class={cn('text-lg leading-none font-semibold', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: DialogPrimitive.TriggerProps = $props();
</script>
<DialogPrimitive.Trigger bind:ref data-slot="dialog-trigger" {...restProps} />
@@ -0,0 +1,7 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
let { open = $bindable(false), ...restProps }: DialogPrimitive.RootProps = $props();
</script>
<DialogPrimitive.Root bind:open {...restProps} />
+34
View File
@@ -0,0 +1,34 @@
import Root from './dialog.svelte';
import Portal from './dialog-portal.svelte';
import Title from './dialog-title.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Trigger from './dialog-trigger.svelte';
import Close from './dialog-close.svelte';
export {
Root,
Title,
Portal,
Footer,
Header,
Trigger,
Overlay,
Content,
Description,
Close,
//
Root as Dialog,
Title as DialogTitle,
Portal as DialogPortal,
Footer as DialogFooter,
Header as DialogHeader,
Trigger as DialogTrigger,
Overlay as DialogOverlay,
Content as DialogContent,
Description as DialogDescription,
Close as DialogClose
};
+7
View File
@@ -0,0 +1,7 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input
};
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> &
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
'data-slot': dataSlot = 'input',
...restProps
}: Props = $props();
</script>
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'flex h-9 w-full min-w-0 rounded-md border border-input bg-background px-3 py-1 text-base shadow-xs ring-offset-background transition-[color,box-shadow] outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30',
'focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from './label.svelte';
export {
Root,
//
Root as Label
};
+20
View File
@@ -0,0 +1,20 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className
)}
{...restProps}
/>
+37
View File
@@ -0,0 +1,37 @@
import Root from './select.svelte';
import Group from './select-group.svelte';
import Label from './select-label.svelte';
import Item from './select-item.svelte';
import Content from './select-content.svelte';
import Trigger from './select-trigger.svelte';
import Separator from './select-separator.svelte';
import ScrollDownButton from './select-scroll-down-button.svelte';
import ScrollUpButton from './select-scroll-up-button.svelte';
import GroupHeading from './select-group-heading.svelte';
import Portal from './select-portal.svelte';
export {
Root,
Group,
Label,
Item,
Content,
Trigger,
Separator,
ScrollDownButton,
ScrollUpButton,
GroupHeading,
Portal,
//
Root as Select,
Group as SelectGroup,
Label as SelectLabel,
Item as SelectItem,
Content as SelectContent,
Trigger as SelectTrigger,
Separator as SelectSeparator,
ScrollDownButton as SelectScrollDownButton,
ScrollUpButton as SelectScrollUpButton,
GroupHeading as SelectGroupHeading,
Portal as SelectPortal
};
@@ -0,0 +1,45 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import SelectPortal from './select-portal.svelte';
import SelectScrollUpButton from './select-scroll-up-button.svelte';
import SelectScrollDownButton from './select-scroll-down-button.svelte';
import { cn, type WithoutChild } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
portalProps,
children,
preventScroll = true,
...restProps
}: WithoutChild<SelectPrimitive.ContentProps> & {
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof SelectPortal>>;
} = $props();
</script>
<SelectPortal {...portalProps}>
<SelectPrimitive.Content
bind:ref
{sideOffset}
{preventScroll}
data-slot="select-content"
class={cn(
'relative z-50 max-h-(--bits-select-content-available-height) min-w-[8rem] origin-(--bits-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:translate-y-1 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:-translate-x-1 data-[side=left]:slide-in-from-end-2 data-[side=right]:translate-x-1 data-[side=right]:slide-in-from-start-2 data-[side=top]:-translate-y-1 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
className
)}
{...restProps}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
class={cn(
'h-(--bits-select-anchor-height) w-full min-w-(--bits-select-anchor-width) scroll-my-1 p-1'
)}
>
{@render children?.()}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPortal>
@@ -0,0 +1,21 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: ComponentProps<typeof SelectPrimitive.GroupHeading> = $props();
</script>
<SelectPrimitive.GroupHeading
bind:ref
data-slot="select-group-heading"
class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</SelectPrimitive.GroupHeading>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ref = $bindable(null), ...restProps }: SelectPrimitive.GroupProps = $props();
</script>
<SelectPrimitive.Group bind:ref data-slot="select-group" {...restProps} />
@@ -0,0 +1,38 @@
<script lang="ts">
import CheckIcon from '@lucide/svelte/icons/check';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
value,
label,
children: childrenProp,
...restProps
}: WithoutChild<SelectPrimitive.ItemProps> = $props();
</script>
<SelectPrimitive.Item
bind:ref
{value}
data-slot="select-item"
class={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 ps-2 pe-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...restProps}
>
{#snippet children({ selected, highlighted })}
<span class="absolute end-2 flex size-3.5 items-center justify-center">
{#if selected}
<CheckIcon class="size-4" />
{/if}
</span>
{#if childrenProp}
{@render childrenProp({ selected, highlighted })}
{:else}
{label || value}
{/if}
{/snippet}
</SelectPrimitive.Item>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {} = $props();
</script>
<div
bind:this={ref}
data-slot="select-label"
class={cn('px-2 py-1.5 text-xs text-muted-foreground', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let { ...restProps }: SelectPrimitive.PortalProps = $props();
</script>
<SelectPrimitive.Portal {...restProps} />
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollDownButtonProps> = $props();
</script>
<SelectPrimitive.ScrollDownButton
bind:ref
data-slot="select-scroll-down-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronDownIcon class="size-4" />
</SelectPrimitive.ScrollDownButton>
@@ -0,0 +1,20 @@
<script lang="ts">
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import { Select as SelectPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<SelectPrimitive.ScrollUpButtonProps> = $props();
</script>
<SelectPrimitive.ScrollUpButton
bind:ref
data-slot="select-scroll-up-button"
class={cn('flex cursor-default items-center justify-center py-1', className)}
{...restProps}
>
<ChevronUpIcon class="size-4" />
</SelectPrimitive.ScrollUpButton>
@@ -0,0 +1,18 @@
<script lang="ts">
import type { Separator as SeparatorPrimitive } from 'bits-ui';
import { Separator } from '$lib/components/ui/separator/index.js';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<Separator
bind:ref
data-slot="select-separator"
class={cn('pointer-events-none -mx-1 my-1 h-px bg-border', className)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { cn, type WithoutChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
size = 'default',
...restProps
}: WithoutChild<SelectPrimitive.TriggerProps> & {
size?: 'sm' | 'default';
} = $props();
</script>
<SelectPrimitive.Trigger
bind:ref
data-slot="select-trigger"
data-size={size}
class={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDownIcon class="size-4 opacity-50" />
</SelectPrimitive.Trigger>
@@ -0,0 +1,11 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
let {
open = $bindable(false),
value = $bindable(),
...restProps
}: SelectPrimitive.RootProps = $props();
</script>
<SelectPrimitive.Root bind:open bind:value={value as never} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from './separator.svelte';
export {
Root,
//
Root as Separator
};
@@ -0,0 +1,21 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
'data-slot': dataSlot = 'separator',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
data-slot={dataSlot}
class={cn(
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px',
className
)}
{...restProps}
/>
+7
View File
@@ -0,0 +1,7 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
'peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=unchecked]:bg-input/80',
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
'pointer-events-none block size-4 rounded-full bg-background ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0 dark:data-[state=checked]:bg-primary-foreground dark:data-[state=unchecked]:bg-foreground'
)}
/>
</SwitchPrimitive.Root>
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
+45
View File
@@ -0,0 +1,45 @@
import { persisted } from 'svelte-persisted-store';
export interface GeoLocation {
id: number;
name: string;
latitude: number;
longitude: number;
elevation: number;
feature_code: string;
country_code: string | undefined;
admin1_id: number | undefined;
admin3_id?: number | undefined;
admin4_id?: number | undefined;
timezone: string;
population: number | undefined;
postcodes: string[] | undefined;
country_id: number | undefined;
country: string | undefined;
admin1: string | undefined;
admin3?: string | undefined;
admin4?: string | undefined;
}
export const defaultLocation: GeoLocation = {
id: 2950159,
name: 'Berlin',
latitude: 52.52437,
longitude: 13.41053,
elevation: 74,
feature_code: 'PPLC',
country_code: 'DE',
admin1_id: 2950157,
admin3_id: 6547383,
admin4_id: 6547539,
timezone: 'Europe/Berlin',
population: 3426354,
postcodes: ['10967', '13347'],
country_id: 2921044,
country: 'Germany',
admin1: 'Land Berlin',
admin3: 'Berlin, Stadt',
admin4: 'Berlin'
};
export const storedLocation = persisted('stored_location', defaultLocation as GeoLocation);
+23
View File
@@ -0,0 +1,23 @@
import { writable } from 'svelte/store';
// Placeholder function for urlHashStore
// In a real application, this would handle URL hash parameters
// and return a Svelte store that reflects those parameters.
export function urlHashStore(initialValue: Record<string, any>) {
const { subscribe, set, update } = writable(initialValue);
// In a full implementation, you would add logic here to:
// 1. Read the URL hash on initialization
// 2. Parse the hash into an object
// 3. Update the store with these values
// 4. Listen for changes to the store and update the URL hash accordingly
// 5. Listen for URL hash changes (e.g., back/forward buttons) and update the store
return {
subscribe,
set,
update,
// You might want to add methods to easily update specific hash parameters
updateParam: (key: string, value: any) => update((current) => ({ ...current, [key]: value }))
};
}
+20
View File
@@ -0,0 +1,20 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
export const pad = (n: string | number) => {
if (n === null || n === undefined) {
return '';
}
return ('0' + n).slice(-2);
};
+7
View File
@@ -0,0 +1,7 @@
export function geoLocationNameToRoute(name: string): string {
// Placeholder implementation
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-*|-*$/g, '');
}
+9
View File
@@ -0,0 +1,9 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
+8
View File
@@ -0,0 +1,8 @@
<script>
import { base } from '$app/paths';
</script>
<h1>Open-Meteo Weather</h1>
<p>
<a href="{base}/weather">Weather Forecast</a>
</p>
+121
View File
@@ -0,0 +1,121 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
+13
View File
@@ -0,0 +1,13 @@
import { page } from 'vitest/browser';
import { describe, expect, it } from 'vitest';
import { render } from 'vitest-browser-svelte';
import Page from './+page.svelte';
describe('/+page.svelte', () => {
it('should render h1', async () => {
render(Page);
const heading = page.getByRole('heading', { level: 1 });
await expect.element(heading).toBeInTheDocument();
});
});
+97
View File
@@ -0,0 +1,97 @@
<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 { storedLocation } from '$lib/stores/settings';
let location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
interface Props {
children?: import('svelte').Snippet;
}
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 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;
}
}
return {};
});
let mobileNavOpened = $state(false);
</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>
<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>
</div>
+20
View File
@@ -0,0 +1,20 @@
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 ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country,
heroImage: '/images/backgrounds/partly_cloudy.webp',
heroHeight: 400,
heroPrimaryButtonPath: null,
heroPrimaryButtonText: null,
heroSecondaryButtonPath: null,
heroSecondaryButtonText: null
};
};
+703
View File
@@ -0,0 +1,703 @@
<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>
+14
View File
@@ -0,0 +1,14 @@
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: `14 Day Weather ${location.name}`,
heroDescription: location.admin1 ?? '' + ' ' + location.country
};
};
+331
View File
@@ -0,0 +1,331 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
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';
let node: HTMLElement;
let chart: any;
let Highcharts = $state(null);
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation);
const params = urlHashStore({
latitude: [52.52],
longitude: [13.41],
...defaultParameters,
hourly: ['temperature_2m'],
models: ['gfs_seamless']
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
const more = (await import('highcharts/highcharts-more')).default;
// more(Highcharts);
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (await import('highcharts/es-modules/Extensions/Debugger/Debugger.js'))
.default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')
).default;
Highcharts.errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
});
$effect(async () => {
count = 0;
if (Highcharts) {
node.replaceChildren([]);
const dataDaily = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&timeformat=unixtime&daily=sunset,sunrise&forecast_days=14`
);
const wd = await dataDaily.json();
const dataReq = await fetch(
`https://ensemble-api.open-meteo.com/v1/ensemble?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${$params.hourly.join(',')}&models=${$params.models.join(',')}&timeformat=unixtime&forecast_days=14`
);
const data = await dataReq.json();
let plotBands: any = [];
if ('daily' in wd && 'sunrise' in wd.daily && 'sunset' in wd.daily) {
let rise = wd.daily.sunrise;
let set = wd.daily.sunset;
plotBands = rise.map(function (r, i) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
let minValues = new Array(data.hourly.time.length).fill(undefined);
let maxValues = new Array(data.hourly.time.length).fill(undefined);
for (let variable of $params.hourly) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
let average = new Array(data.hourly.time.length).fill(0);
let averageCount = new Array(data.hourly.time.length).fill(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) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
if (minValues[index] > val || minValues[index] === undefined) {
minValues[index] = val;
}
if (maxValues[index] < val || maxValues[index] === undefined) {
maxValues[index] = val;
}
}
}
unit = data.hourly_units[model];
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
const minMax = [];
for (let [index, min] of minValues.entries()) {
minMax.push([min, maxValues[index]]);
}
series.push({
name: 'temperature_2m_spread',
data: minMax,
type: 'arearange',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-spread-series'
});
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' ? 'column' : 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts.Chart(chartDiv, {
credits: {
text: count === $params.hourly.length - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Spread' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly.join(', ')}</span> in models: <span class="font-bold">` +
$params.models.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
}
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * $params.hourly.length + 2}px]">
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
</div>
<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"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
$params.hourly = $params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
$params.hourly = $params.hourly;
}}
/>
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
+33
View File
@@ -0,0 +1,33 @@
export const defaultParameters = {
daily: [],
hourly: [],
models: [],
current: [],
minutely_15: [],
timezone: 'UTC',
location_mode: 'location_search',
csv_coordinates: undefined,
time_mode: 'forecast_days',
past_days: '0',
forecast_days: '14',
end_date: undefined,
start_date: undefined,
past_hours: undefined,
cell_selection: undefined,
forecast_hours: undefined,
past_minutely_15: undefined,
temporal_resolution: undefined,
forecast_minutely_15: undefined,
tilt: '0',
azimuth: '0',
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
+78
View File
@@ -0,0 +1,78 @@
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement | null
): void => {
if (ctx && series) {
ctx.beginPath();
ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30);
for (const [index, value] of series.entries()) {
ctx.strokeStyle = '#444';
ctx.lineWidth = 0.1;
const nextValue = series[index + 1];
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX + 1.5 * config.deltaX)) /
2;
const yc = (35 + (value ** 1.5 / 1000) * 30 + 35 + (nextValue ** 1.5 / 1000) * 30) / 2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
35 + (value ** 1.5 / 1000) * 30,
xc,
yc
);
}
ctx.quadraticCurveTo(
config.maxX,
35 + (series[series.length - 1] ** 1.5 / 1000) * 30,
config.maxX,
35 + (series[series.length - 1] ** 1.5 / 1000) * 30
);
ctx.quadraticCurveTo(
config.maxX,
35 - (series[series.length - 1] ** 1.5 / 1000) * 30,
config.maxX,
35 - (series[series.length - 1] ** 1.5 / 1000) * 30
);
// same series but reversed
for (const [ind, v] of series.entries()) {
const index = series.length - 1 - ind;
const value = series[index];
const nextValue = series[index - 1];
ctx.strokeStyle = '#444';
ctx.lineWidth = 0.1;
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX - 0.5 * config.deltaX)) /
2;
const yc = (35 - (value ** 2 / 10000) * 30 + (35 - (nextValue ** 2 / 10000) * 30)) / 2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
35 - (value ** 2 / 10000) * 30,
xc,
yc
);
}
ctx.quadraticCurveTo(
0.5 * config.deltaX,
35 - (series[0] ** 1.5 / 1000) * 30,
0,
35 - (series[0] ** 1.5 / 1000) * 30
);
ctx.closePath();
//to fill the space in the shape
ctx.fillStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--muted-foreground').split(' ').join(',')}, 0.5)`;
ctx.fill();
}
};
+20
View File
@@ -0,0 +1,20 @@
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Date[]
): void => {
if (ctx) {
for (const [index, value] of series.entries()) {
if (value.getHours() > 6 && value.getHours() < 21) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX, config.maxY);
ctx.lineTo(index * config.deltaX, 0);
ctx.lineTo((index + 1) * config.deltaX, 0);
ctx.lineTo((index + 1) * config.deltaX, config.maxY);
ctx.closePath();
ctx.fillStyle = '#f4ff0014';
ctx.fill();
}
}
}
};
+20
View File
@@ -0,0 +1,20 @@
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement | null
): void => {
if (ctx && series) {
for (const [index, value] of series.entries()) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY);
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--primary').split(' ').join(',')}, 1)`;
ctx.lineWidth = 12;
ctx.lineTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY - value ** 0.55 * 45);
ctx.stroke();
ctx.closePath();
}
}
};
+42
View File
@@ -0,0 +1,42 @@
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Date[],
today: Date,
canvasElement: HTMLCanvasElement | null
): void => {
if (ctx && series) {
for (const [index, _] of series.entries()) {
ctx.beginPath();
ctx.moveTo(index * config.deltaX, 0);
ctx.lineTo(index * config.deltaX, config.maxY);
if (series[index].getHours() === 0) {
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 1)`;
ctx.lineWidth = 3;
} else if (
series[index].getDate() === today.getDate() &&
series[index].getHours() === today.getHours()
) {
// fill now line
// TODO: update this line every minute
ctx.stroke();
ctx.closePath();
ctx.beginPath();
ctx.strokeStyle = 'red';
ctx.lineWidth = 5;
let 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();
ctx.closePath();
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
ctx.lineWidth = 1;
} else {
ctx.strokeStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--border').split(' ').join(',')}, 0.75)`;
ctx.lineWidth = 1;
}
ctx.stroke();
ctx.closePath();
}
}
};
@@ -0,0 +1,60 @@
import { getColor } from '../utils/colors';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
unit = 'celsius'
): void => {
if (ctx && series) {
const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY);
tempGradientFill.addColorStop(0, getColor(config.maxTemp.toFixed(0), unit) + '5c');
tempGradientFill.addColorStop(0.25, getColor(config.maxTemp.toFixed(0), unit) + '5c');
tempGradientFill.addColorStop(0.85, getColor(config.minTemp.toFixed(0), unit) + '06');
tempGradientFill.addColorStop(1, getColor(config.minTemp.toFixed(0), unit) + '00');
ctx.beginPath();
ctx.moveTo(
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()) {
const indexDiffTemp = config.maxTemp - value;
const indexDiffTempNext = config.maxTemp - series[index + 1];
ctx.strokeStyle = '#d3d3d3';
ctx.lineWidth = 4;
const xc =
(index * config.deltaX +
0.5 * config.deltaX +
(index * config.deltaX + 1.5 * config.deltaX)) /
2;
const yc =
(0.25 * config.maxY +
(indexDiffTemp / config.diffTemp) * 0.55 * config.maxY +
(0.25 * config.maxY + (indexDiffTempNext / config.diffTemp) * 0.55 * config.maxY)) /
2;
ctx.quadraticCurveTo(
index * config.deltaX + 0.5 * config.deltaX,
0.25 * config.maxY + (indexDiffTemp / config.diffTemp) * 0.55 * config.maxY,
xc,
yc
);
}
ctx.quadraticCurveTo(
config.maxX,
0.25 * config.maxY +
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY,
(config.maxX + config.maxX + config.deltaX) / 2,
0.25 * config.maxY +
((config.maxTemp - series[series.length - 1]) / config.diffTemp) * 0.55 * config.maxY
);
ctx.lineTo(config.maxX, config.maxY);
ctx.lineTo(0, config.maxY);
ctx.closePath();
ctx.fillStyle = tempGradientFill;
ctx.fill();
}
};
+14
View File
@@ -0,0 +1,14 @@
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
};
};
+434
View File
@@ -0,0 +1,434 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade } from 'svelte/transition';
import { dev } from '$app/environment';
import { storedLocation } from '$lib/stores/settings';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Checkbox } from '$lib/components/ui/checkbox';
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';
let node: HTMLElement;
let chart: any;
let Highcharts = $state();
let showLegend = $state(false);
let averageOnly = $state(false);
const location = get(storedLocation);
const params = urlHashStore({
latitude: [52.52],
longitude: [13.41],
...defaultParameters,
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
models: [
'ecmwf_ifs025',
'meteofrance_seamless',
'ukmo_seamless',
'icon_seamless',
'gem_seamless'
]
});
let count = $state(0);
onMount(async () => {
/// Highcharts needs to be loaded in `onMount` to work with prerendered SSG
Highcharts = (await import('highcharts')).default;
if (dev) {
// const HighchartsDebugger = await import('highcharts/modules/debugger');
// HighchartsDebugger.default(Highcharts);
const Debugger = (await import('highcharts/es-modules/Extensions/Debugger/Debugger.js'))
.default;
const ErrorMessages = (
await import('highcharts/es-modules/Extensions/Debugger/ErrorMessages.js')
).default;
Highcharts.errorMessages = ErrorMessages;
Debugger.compose(Highcharts.Chart);
}
});
$effect(async () => {
count = 0;
if (Highcharts) {
node.replaceChildren([]);
const dataReq = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&hourly=${$params.hourly.join(',')}&models=${$params.models.join(',')}&timeformat=unixtime&daily=sunset,sunrise`
);
const data = await dataReq.json();
let dailyFirstModelKey = Object.keys(data.daily)[1].split('_');
dailyFirstModelKey.shift();
dailyFirstModelKey = dailyFirstModelKey.join('_');
let plotBands: any = [];
if (
'daily' in data &&
'sunrise_' + dailyFirstModelKey in data.daily &&
'sunset_' + dailyFirstModelKey in data.daily
) {
let rise = data.daily['sunrise_' + dailyFirstModelKey];
let set = data.daily['sunset_' + dailyFirstModelKey];
plotBands = rise.map(function (r, i) {
return {
color: 'rgba(255, 255, 194, 0.5)',
from: (r + data.utc_offset_seconds) * 1000,
to: (set[i] + data.utc_offset_seconds) * 1000
};
});
}
for (let variable of $params.hourly) {
const chartDiv = document.createElement('div');
let unit;
let hourly_starttime = (data.hourly.time[0] + data.utc_offset_seconds) * 1000;
let pointInterval = (data.hourly.time[1] - data.hourly.time[0]) * 1000;
const series = [];
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) {
let avVal = average[index];
average[index] = avVal + val;
averageCount[index]++;
}
}
unit = data.hourly_units[model];
if (!averageOnly) {
series.push({
name: model,
data: values,
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²'
? 'column'
: 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
pointStart: hourly_starttime,
pointInterval: pointInterval
});
}
variableCount++;
}
}
for (let [index, val] of average.entries()) {
average[index] = Math.round((val / averageCount[index]) * 10) / 10;
}
series.push({
name: variable + '_average',
data: average,
dashStyle: 'ShortDashDot',
color: '#5e5e5e',
type:
unit == 'mm' || unit == 'cm' || unit == 'inch' || unit == 'MJ/m²' ? 'column' : 'spline',
tooltip: {
valueSuffix: ' ' + unit
},
lineWidth: 4,
states: {
hover: {
lineWidth: 6
}
},
pointStart: hourly_starttime,
pointInterval: pointInterval,
className: 'highcharts-average-series'
});
new Highcharts.Chart(chartDiv, {
credits: {
text: count === $params.hourly.length - 1 ? 'Open-Meteo.com' : '',
href: 'http://open-meteo.com'
},
chart: {
height: showLegend ? '400px' : '300px',
styledMode: true,
marginLeft: '50',
marginRight: 0
},
lang: {
locale: 'en-GB'
},
title: {
text: count === 0 ? 'Model Compare' : '',
align: 'left'
},
subtitle: {
text:
count === 0
? `Compare <span class="font-bold">${$params.hourly.join(', ')}</span> in models: <span class="font-bold">` +
$params.models.join(', ') +
'</span>'
: '',
align: 'left'
},
yAxis: {
title: {
text: unit
}
},
xAxis: {
type: 'datetime',
plotLines: [
{
value: Date.now() + data.utc_offset_seconds * 1000,
color: 'red',
width: 2
}
],
plotBands: plotBands
},
plotOptions: {
spline: {
lineWidth: 2,
states: {
hover: {
lineWidth: 3
}
},
marker: {
enabled: false
}
},
column: {
pointWidth: 5
}
},
legend: {
enabled: showLegend,
layout: 'horizontal',
align: 'center',
verticalAlign: 'bottom'
},
series: series,
responsive: {
rules: [
{
condition: {
maxWidth: 800
}
}
]
},
tooltip: {
shared: true,
animation: false
}
});
count++;
node.appendChild(chartDiv);
}
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<!-- min-h-[302px] min-h-[602px] min-h-[902px] min-h-[1202px] -->
<div class="container-wrapper relative -mx-6 md:mx-0 min-h-[{300 * $params.hourly.length + 2}px]">
<div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }} bind:this={node}></div>
<div
class="{count > 0
? 'pointer-events-none opacity-0'
: 'opacity-100'} absolute top-0 z-30 flex h-full w-full items-center justify-center rounded-lg bg-accent/100"
>
<svg
class="lucide lucide-loader-circle animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
<span class="hidden">Loading...</span>
</div>
</div>
<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"
name="Show legend"
bind:checked={showLegend}
onCheckedChange={() => {
$params.hourly = $params.hourly;
}}
/>
<Label for="show_legend" class="mb-[2px] cursor-pointer text-lg">Show legend</Label>
</div>
<div class="flex gap-2">
<Switch
id="average_only"
name="Average only"
bind:checked={averageOnly}
onCheckedChange={() => {
$params.hourly = $params.hourly;
}}
/>
<Label for="average_only" class="mb-[2px] cursor-pointer text-lg">Average only</Label>
</div>
</div>
</div>
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if $params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{$params.models.length}&nbsp;/&nbsp;{models.flat().length}
</div>
</div>
{/if}
</div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4">
{#each models as group, i (i)}
<div class="mb-3">
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_model"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={$params.models?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if ($params.models?.includes(value)) {
$params.models = $params.models.filter((item) => {
return item !== value;
});
} else if ($params.models) {
$params.models.push(value);
$params.models = $params.models;
}
}}
/>
<Label
id="{value}_model_label"
for="{value}_model"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
<!-- HOURLY -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
</h2></a
>
{#if $params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-[5px]">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{$params.hourly.length}&nbsp;/&nbsp;{hourly.flat().length}
</div>
</div>
{/if}
</div>
<div
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{#each hourly as group, i (i)}
<div>
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={$params.hourly?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if ($params.hourly?.includes(value)) {
$params.hourly = $params.hourly.filter((item) => {
return item !== value;
});
} else if ($params.hourly) {
$params.hourly.push(value);
$params.hourly = $params.hourly;
}
}}
/>
<Label
id="{value}_label"
for="{value}_hourly"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
export const defaultParameters = {
daily: [],
hourly: [],
models: [],
current: [],
minutely_15: [],
timezone: 'UTC',
location_mode: 'location_search',
csv_coordinates: undefined,
time_mode: 'forecast_days',
past_days: '0',
forecast_days: '7',
end_date: undefined,
start_date: undefined,
past_hours: undefined,
cell_selection: undefined,
forecast_hours: undefined,
past_minutely_15: undefined,
temporal_resolution: undefined,
forecast_minutely_15: undefined,
tilt: '0',
azimuth: '0',
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
+8
View File
@@ -0,0 +1,8 @@
interface ConfigInterface {
maxX: number;
maxY: number;
deltaX: number;
minTemp: number;
maxTemp: number;
diffTemp: number;
}
@@ -0,0 +1,12 @@
[
"kinshasa",
"shenzhen",
"shanghai",
"guangzhou",
"chengdu",
"beijing",
"mumbai",
"lagos",
"lahore",
"istanbul"
]
@@ -0,0 +1,102 @@
[
"dubai",
"kabul",
"sydney",
"melbourne",
"dhaka",
"chattogram",
"sao-paulo",
"rio-de-janeiro",
"kinshasa",
"abidjan",
"santiago",
"zhengzhou",
"xi'an",
"xiamen",
"wuxi",
"wuhan",
"tianjin",
"tangshan",
"taiyuan",
"shiyan",
"shijiazhuang",
"shenzhen",
"shantou",
"shanghai",
"qingdao",
"puyang",
"ningbo",
"nanning",
"nanjing",
"kunming",
"jinan",
"hefei",
"hangzhou",
"guangzhou",
"fuzhou",
"foshan",
"dongguan",
"dalian",
"chongqing",
"chengdu",
"beijing",
"suzhou",
"shenyang",
"harbin",
"changchun",
"zhongshan",
"bogota",
"berlin",
"cairo",
"giza",
"alexandria",
"addis-ababa",
"london",
"hong-kong",
"new-territories",
"jakarta",
"surat",
"chennai",
"hyderabad",
"delhi",
"kolkata",
"mumbai",
"bengaluru",
"ahmedabad",
"baghdad",
"tehran",
"yokohama",
"tokyo",
"nairobi",
"seoul",
"busan",
"casablanca",
"bamako",
"yangon",
"mexico-city",
"lagos",
"kano",
"ibadan",
"lima",
"peshawar",
"lahore",
"karachi",
"faisalabad",
"saint-petersburg",
"moscow",
"jeddah",
"riyadh",
"singapore",
"bangkok",
"ankara",
"istanbul",
"taipei",
"new-taipei-city",
"dar-es-salaam",
"new-york-city",
"los-angeles",
"ho-chi-minh-city",
"hanoi",
"johannesburg",
"cape-town"
]
+64
View File
@@ -0,0 +1,64 @@
export const defaultParameters = {
timeformat: 'iso8601',
wind_speed_unit: 'kmh',
temperature_unit: 'celsius',
precipitation_unit: 'mm'
};
export const models = [
{ value: 'best_match', label: 'Best match' },
{ value: 'gfs_seamless', label: 'NCEP GFS Seamless' },
{ value: 'jma_seamless', label: 'JMA Seamless' },
{ value: 'kma_seamless', label: 'KMA Seamless' },
{ value: 'icon_seamless', label: 'DWD ICON Seamless' },
{ value: 'gem_seamless', label: 'GEM Seamless' },
{ value: 'meteofrance_seamless', label: 'Météo-France Seamless' },
{ value: 'arpae_cosmo_seamless', label: 'ARPAE Seamless' },
{ value: 'metno_seamless', label: 'MET Norway Seamless (with ECMWF)' },
{ value: 'knmi_seamless', label: 'KNMI Seamless (with ECMWF)' },
{ value: 'dmi_seamless', label: 'DMI Seamless (with ECMWF)' },
{ value: 'ukmo_seamless', label: 'UK Met Office Seamless' }
];
export const hourly = [
[
{ value: 'temperature_2m', label: 'Temperature 2m' },
{ value: 'relative_humidity_2m', label: 'Relative Humidity 2m' },
{ value: 'dew_point_2m', label: 'Dew Point 2m' },
{ value: 'apparent_temperature', label: 'Apparent Temperature' },
{ value: 'precipitation_probability', label: 'Precipitation Probability' }
],
[
{ value: 'precipitation', label: 'Precipitation' },
{ value: 'rain', label: 'Rain' },
{ value: 'showers', label: 'Showers' },
{ value: 'snowfall', label: 'Snowfall' },
{ value: 'weather_code', label: 'Weather Code' }
],
[
{ value: 'pressure_msl', label: 'Pressure MSL' },
{ value: 'surface_pressure', label: 'Surface Pressure' },
{ value: 'cloud_cover', label: 'Cloud Cover' },
{ value: 'cloud_cover_low', label: 'Cloud Cover Low' },
{ value: 'cloud_cover_mid', label: 'Cloud Cover Mid' },
{ value: 'cloud_cover_high', label: 'Cloud Cover High' }
],
[
{ value: 'et0_fao_evapotranspiration', label: 'Evapotranspiration' },
{ value: 'vapor_pressure_deficit', label: 'Vapor Pressure Deficit' },
{ value: 'wind_speed_10m', label: 'Wind Speed 10m' },
{ value: 'wind_speed_80m', label: 'Wind Speed 80m' },
{ value: 'wind_speed_120m', label: 'Wind Speed 120m' },
{ value: 'wind_speed_180m', label: 'Wind Speed 180m' },
{ value: 'wind_direction_10m', label: 'Wind Direction 10m' },
{ value: 'wind_direction_80m', label: 'Wind Direction 80m' },
{ value: 'wind_direction_120m', label: 'Wind Direction 120m' },
{ value: 'wind_direction_180m', label: 'Wind Direction 180m' },
{ value: 'wind_gusts_10m', label: 'Wind Gusts 10m' }
],
[
{ value: 'temperature_80m', label: 'Temperature 80m' },
{ value: 'temperature_120m', label: 'Temperature 120m' },
{ value: 'temperature_180m', label: 'Temperature 180m' }
]
];
+102
View File
@@ -0,0 +1,102 @@
export default [
'#800080',
'#800083',
'#800087',
'#7f008a',
'#7f008d',
'#7e0090',
'#7d0094',
'#7c0097',
'#7a009a',
'#79009d',
'#7700a1',
'#7600a4',
'#7400a7',
'#7200aa',
'#6f00ae',
'#6d00b1',
'#6a00b4',
'#6700b7',
'#6400bb',
'#6100be',
'#5e00c1',
'#5b00c4',
'#5700c8',
'#5300cb',
'#4f00ce',
'#4b00d1',
'#4700d5',
'#4200d8',
'#3e00db',
'#3900de',
'#3400e2',
'#2f00e5',
'#2a00e8',
'#2400eb',
'#1f00ef',
'#1900f2',
'#1300f5',
'#0d00f8',
'#0600fc',
'#0000ff',
'#0000ff',
'#0021f7',
'#003fee',
'#005ce6',
'#0076dd',
'#008ed5',
'#00a3cc',
'#00b7c4',
'#00bbaf',
'#00b38f',
'#00aa72',
'#00a256',
'#00993d',
'#009127',
'#008812',
'#008000',
'#008000',
'#118c00',
'#259700',
'#3ca300',
'#56ae00',
'#72ba00',
'#92c500',
'#b4d100',
'#d9dc00',
'#e8cf00',
'#f3bb00',
'#ffa500',
'#ffa500',
'#ff9800',
'#ff8c00',
'#ff7f00',
'#ff7200',
'#ff6600',
'#ff5900',
'#ff4c00',
'#ff3f00',
'#ff3300',
'#ff2600',
'#ff1900',
'#ff0d00',
'#ff0000',
'#ff0000',
'#f8000f',
'#f0001c',
'#e90029',
'#e10035',
'#da0040',
'#d2004a',
'#cb0053',
'#c3005c',
'#bc0063',
'#b4006a',
'#ad0070',
'#a50075',
'#9e0079',
'#96007c',
'#8f007e',
'#870080',
'#800080'
];
+51
View File
@@ -0,0 +1,51 @@
import colorScaleHex from './color-scale-hex';
function componentFromStr(numStr: string, percent: number) {
const num = Math.max(0, parseInt(numStr, 10));
return percent ? Math.floor((255 * Math.min(100, num)) / 100) : Math.min(255, num);
}
export function rgbToHex(rgb: string) {
const rgbRegex = /^rgb\(\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*,\s*(-?\d+)(%?)\s*\)$/;
let result,
r,
g,
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]);
hex = (0x1000000 + (r << 16) + (g << 8) + b).toString(16).slice(1);
}
if (!rgb) {
return '355522';
}
return hex;
}
export const getColor = (temp: number, unit = 'celsius'): string => {
let index = 0;
temp = Number(temp);
if (unit === 'celsius') {
if (temp <= -40) {
index = 0;
} else if (temp >= 60) {
index = colorScaleHex.length - 1;
} else {
index = temp + 40;
}
} else {
const tempInCelsius = Math.round(((temp - 32) * 5) / 9);
if (tempInCelsius <= -40) {
index = 0;
} else if (tempInCelsius >= 60) {
index = colorScaleHex.length - 1;
} else {
index = tempInCelsius + 40;
}
}
return colorScaleHex[index];
};
File diff suppressed because one or more lines are too long
+81
View File
@@ -0,0 +1,81 @@
export default {
0: 'clear',
1: 'clear',
2: 'cloudy',
3: 'cloudy',
4: 'fog',
5: 'fog',
10: 'fog',
11: 'fog',
12: 'lightning',
18: 'strong-wind',
20: 'fog',
21: 'rain-mix',
22: 'rain-mix',
23: 'rain',
24: 'snow',
25: 'hail',
26: 'thunderstorm',
27: 'dust',
28: 'dust',
29: 'dust',
30: 'fog',
31: 'fog',
32: 'fog',
33: 'fog',
34: 'fog',
35: 'fog',
40: 'rain-mix',
41: 'sprinkle',
42: 'rain',
43: 'sprinkle',
44: 'rain',
45: 'hail',
46: 'hail',
47: 'snow',
48: 'snow',
50: 'sprinkle',
51: 'sprinkle',
52: 'rain',
53: 'rain',
54: 'snowflake-cold',
55: 'snowflake-cold',
56: 'snowflake-cold',
57: 'sprinkle',
58: 'rain',
60: 'sprinkle',
61: 'sprinkle',
62: 'rain',
63: 'rain',
64: 'hail',
65: 'hail',
66: 'hail',
67: 'rain-mix',
68: 'rain-mix',
70: 'snow',
71: 'snow',
72: 'snow',
73: 'snow',
74: 'snowflake-cold',
75: 'snowflake-cold',
76: 'snowflake-cold',
77: 'snow',
78: 'snowflake-cold',
80: 'rain',
81: 'sprinkle',
82: 'rain',
83: 'rain',
84: 'storm-showers',
85: 'rain-mix',
86: 'rain-mix',
87: 'rain-mix',
89: 'hail',
90: 'lightning',
91: 'storm-showers',
92: 'thunderstorm',
93: 'thunderstorm',
94: 'lightning',
95: 'thunderstorm',
96: 'thunderstorm',
99: 'tornado'
};
+14
View File
@@ -0,0 +1,14 @@
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
};
};
+25
View File
@@ -0,0 +1,25 @@
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) => {
const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
throw redirect(
303,
'/en/weather/week/' +
(location.population
? location.population > 543000
? locationRoute
: locationRoute + '_' + location.id
: locationRoute + '_' + location.id)
);
}) satisfies PageLoad;
@@ -0,0 +1,9 @@
<script lang="ts">
let { data } = $props();
const location = data.location;
</script>
<h1>
{location ? location.name : ''}, population: {location.population}
</h1>
@@ -0,0 +1,77 @@
import { error, redirect } from '@sveltejs/kit';
import { type GeoLocation, 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) => {
const urlLocation = event.params.location;
let urlLocationSplit, urlLocationName, urlLocationId;
if (urlLocation.includes('_')) {
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 = urlLocationSplit[0];
const longitude = urlLocationSplit[1];
location = {
//id: undefined,
name: `${latitude}${longitude}`,
latitude: latitude,
longitude: longitude
};
} 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 !== `/en/weather/week/${locationRoute}`) {
throw redirect(303, `/en/weather/week/${locationRoute}`);
}
} else {
if (event.url.pathname !== `/en/weather/week/${locationRoute + '_' + location.id}`) {
throw redirect(303, `/en/weather/week/${locationRoute + '_' + location.id}`);
}
}
}
storedLocation.set(location);
return { location: location };
}) satisfies PageLoad;