initial commit
@@ -0,0 +1,23 @@
|
||||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
@@ -0,0 +1,9 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/routes/layout.css"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"*.css": "tailwindcss"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/routes/layout.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "open-meteo-weather",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test:unit": "vitest",
|
||||
"test": "npm run test:unit -- --run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.4.0",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@internationalized/date": "^3.10.1",
|
||||
"@lucide/svelte": "^0.561.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^24",
|
||||
"@vitest/browser-playwright": "^4.0.15",
|
||||
"bits-ui": "^2.15.4",
|
||||
"clsx": "^2.1.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.13.1",
|
||||
"globals": "^16.5.0",
|
||||
"playwright": "^1.57.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"svelte": "^5.45.6",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-persisted-store": "^0.12.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"vite": "^7.2.6",
|
||||
"vitest": "^4.0.15",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"highcharts": "^12.4.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"openmeteo": "^1.2.3"
|
||||
}
|
||||
}
|
||||
@@ -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 {};
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './input.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input
|
||||
};
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from './label.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -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);
|
||||
@@ -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 }))
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export function geoLocationNameToRoute(name: string): string {
|
||||
// Placeholder implementation
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-*|-*$/g, '');
|
||||
}
|
||||
@@ -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()}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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} / {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} / {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>
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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' }
|
||||
]
|
||||
];
|
||||
@@ -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'
|
||||
];
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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}N° ${longitude}E°`,
|
||||
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;
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M8.75,15.54c-1.12-2.4-0.95-4.66,0.52-6.79c1.03-1.48,2.6-2.39,4.73-2.72c0.16-0.04,0.34-0.07,0.54-0.08h0.63
|
||||
c2.91,0.09,5.05,1.38,6.4,3.88c0.64,1.18,0.8,2.48,0.48,3.91c-0.26,1.13-0.68,2.19-1.28,3.17c-1.29,2.01-2.63,3.64-4,4.88
|
||||
c-0.07,0.07-0.17,0.16-0.3,0.26c-0.46,0.35-0.89,0.53-1.28,0.54s-0.83-0.14-1.31-0.45c-0.29-0.17-0.53-0.37-0.74-0.59
|
||||
C11.18,19.55,9.71,17.55,8.75,15.54z M8.86,13.33c0.02,0.11,0.05,0.25,0.09,0.44s0.07,0.32,0.09,0.4c0.28,1.26,0.86,2.23,1.73,2.93
|
||||
c0.88,0.7,1.96,1.11,3.26,1.23c0.29,0.03,0.46,0.02,0.51-0.03s0.08-0.23,0.09-0.52c-0.01-0.08-0.03-0.21-0.05-0.39
|
||||
c-0.02-0.18-0.04-0.31-0.06-0.39c-0.25-1.34-0.88-2.32-1.9-2.93c-0.18-0.11-0.39-0.22-0.62-0.34s-0.44-0.2-0.61-0.27
|
||||
c-0.17-0.07-0.4-0.16-0.69-0.27c-0.29-0.11-0.5-0.19-0.63-0.25c-0.16-0.06-0.42-0.1-0.8-0.11C8.95,12.83,8.81,13,8.86,13.33z
|
||||
M15.66,17.73c-0.02,0.31,0,0.49,0.06,0.56c0.07,0.07,0.25,0.08,0.55,0.04c0.38-0.04,0.78-0.12,1.2-0.22
|
||||
c1.07-0.27,1.94-0.84,2.62-1.71c0.34-0.41,0.6-0.86,0.77-1.34s0.34-1.05,0.47-1.72c0.05-0.23,0.04-0.38-0.03-0.46
|
||||
c-0.07-0.08-0.22-0.11-0.44-0.08c-0.59,0.1-1.12,0.23-1.59,0.4c-1.15,0.43-2.02,1.01-2.62,1.74C16.05,15.68,15.72,16.6,15.66,17.73z
|
||||
"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M7.69,13.2c0-0.99,0.19-1.94,0.58-2.85c0.39-0.91,0.91-1.68,1.57-2.33s1.44-1.17,2.34-1.56c0.9-0.39,1.85-0.58,2.84-0.58
|
||||
c0.99,0,1.94,0.19,2.85,0.58c0.9,0.39,1.68,0.91,2.33,1.56c0.65,0.65,1.17,1.43,1.56,2.33s0.58,1.85,0.58,2.85
|
||||
c0,1.62-0.48,3.06-1.44,4.34c-0.96,1.27-2.2,2.14-3.71,2.61v3.29h-4.24v-3.25c-1.54-0.45-2.81-1.32-3.79-2.61S7.69,14.83,7.69,13.2z
|
||||
M9.3,13.2c0,1.55,0.56,2.88,1.69,3.99c1.11,1.12,2.45,1.68,4.02,1.68c1.03,0,1.99-0.25,2.86-0.76c0.88-0.51,1.57-1.2,2.09-2.07
|
||||
c0.51-0.87,0.77-1.82,0.77-2.85c0-0.77-0.15-1.5-0.45-2.21s-0.71-1.31-1.22-1.82c-0.51-0.51-1.12-0.92-1.83-1.22
|
||||
c-0.71-0.3-1.44-0.45-2.21-0.45c-0.77,0-1.5,0.15-2.21,0.45s-1.31,0.71-1.82,1.22c-0.51,0.51-0.92,1.12-1.22,1.82
|
||||
C9.45,11.7,9.3,12.43,9.3,13.2z M9.88,13.56v-0.72h2.17v0.72H9.88z M10.97,10.02l0.52-0.52l1.52,1.52l-0.52,0.53L10.97,10.02z
|
||||
M13.5,14.95c0-0.42,0.15-0.78,0.44-1.09c0.29-0.31,0.65-0.47,1.06-0.48l2.73-4.49l0.66,0.35l-2.02,4.83
|
||||
c0.18,0.25,0.26,0.54,0.26,0.88c0,0.44-0.15,0.81-0.46,1.11c-0.31,0.3-0.68,0.45-1.12,0.45c-0.43,0-0.8-0.15-1.1-0.45
|
||||
C13.65,15.76,13.5,15.39,13.5,14.95z M14.81,10.28V8.12h0.69v2.17H14.81z M17.75,13.55v-0.74h2.17v0.74H17.75z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M9.75,10.98c0-0.5,0.18-0.93,0.53-1.28c0.36-0.36,0.78-0.53,1.28-0.53c0.49,0,0.92,0.18,1.27,0.53
|
||||
c0.35,0.36,0.53,0.78,0.53,1.28c0,0.5-0.18,0.93-0.53,1.28c-0.35,0.36-0.78,0.53-1.27,0.53c-0.5,0-0.93-0.18-1.28-0.53
|
||||
S9.75,11.48,9.75,10.98z M10.63,10.98c0,0.26,0.09,0.48,0.27,0.67c0.19,0.19,0.41,0.28,0.67,0.28c0.26,0,0.48-0.09,0.67-0.28
|
||||
s0.28-0.41,0.28-0.67c0-0.26-0.09-0.48-0.28-0.67s-0.41-0.28-0.67-0.28c-0.26,0-0.48,0.09-0.67,0.28
|
||||
C10.72,10.49,10.63,10.72,10.63,10.98z M14.52,15.4c0,0.77,0.21,1.45,0.64,2.05c0.22,0.31,0.53,0.56,0.93,0.75
|
||||
c0.39,0.18,0.84,0.28,1.34,0.28c1.46,0,2.38-0.56,2.75-1.67c0.04-0.14,0.02-0.28-0.06-0.41c-0.08-0.13-0.19-0.2-0.33-0.23
|
||||
c-0.14-0.04-0.28-0.02-0.4,0.07c-0.12,0.08-0.2,0.19-0.23,0.34c0,0.01,0,0.02-0.01,0.05l-0.02,0.07c-0.11,0.19-0.26,0.34-0.45,0.45
|
||||
c-0.31,0.19-0.72,0.28-1.23,0.28c-0.31,0-0.59-0.05-0.83-0.16c-0.4-0.17-0.68-0.47-0.85-0.89c-0.11-0.27-0.17-0.6-0.17-0.97v-3.22
|
||||
c0-0.15,0.01-0.3,0.03-0.45c0.04-0.38,0.19-0.73,0.45-1.04c0.29-0.35,0.75-0.52,1.38-0.52c0.52,0,0.93,0.09,1.23,0.27
|
||||
c0.2,0.12,0.35,0.27,0.45,0.45c0.01,0.02,0.01,0.05,0.02,0.08c0.01,0.03,0.01,0.05,0.01,0.06c0.04,0.14,0.12,0.24,0.23,0.3
|
||||
c0.12,0.07,0.25,0.08,0.4,0.05c0.14-0.03,0.25-0.11,0.33-0.23c0.08-0.12,0.1-0.25,0.06-0.4v-0.01l-0.08-0.23
|
||||
c-0.05-0.11-0.14-0.26-0.28-0.43c-0.13-0.18-0.29-0.32-0.45-0.44c-0.21-0.15-0.48-0.27-0.82-0.38c-0.34-0.1-0.71-0.15-1.11-0.15
|
||||
c-0.51,0-0.95,0.09-1.35,0.27c-0.39,0.18-0.7,0.42-0.91,0.73c-0.43,0.59-0.65,1.28-0.65,2.07V15.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.61,16.88c0,1.34,0.47,2.48,1.4,3.44c0.93,0.96,2.07,1.47,3.4,1.53c0.11,0,0.17-0.06,0.17-0.17v-1.34
|
||||
c0-0.12-0.06-0.18-0.17-0.18c-0.86-0.04-1.59-0.38-2.19-1.02c-0.6-0.64-0.9-1.39-0.9-2.26c0-0.83,0.28-1.55,0.85-2.17
|
||||
c0.57-0.62,1.27-0.97,2.1-1.07L9.8,13.6c0.13,0,0.2-0.06,0.2-0.17l0.08-0.55c0.1-1.08,0.55-1.99,1.36-2.71
|
||||
c0.81-0.73,1.76-1.09,2.86-1.09c1.09,0,2.04,0.36,2.86,1.09c0.82,0.73,1.28,1.63,1.4,2.71l0.07,0.58c0,0.11,0.06,0.17,0.17,0.17
|
||||
h1.62c0.89,0,1.66,0.32,2.31,0.97c0.65,0.64,0.98,1.4,0.98,2.28c0,0.87-0.3,1.62-0.91,2.26c-0.61,0.64-1.34,0.98-2.19,1.02
|
||||
c-0.13,0-0.19,0.06-0.19,0.18v1.34c0,0.11,0.06,0.17,0.19,0.17c0.88-0.02,1.68-0.26,2.41-0.72c0.73-0.45,1.31-1.05,1.73-1.8
|
||||
s0.63-1.57,0.63-2.45c0-0.9-0.22-1.73-0.67-2.48c-0.44-0.76-1.05-1.35-1.81-1.79s-1.59-0.65-2.49-0.65h-0.33
|
||||
c-0.33-1.34-1.03-2.43-2.1-3.29s-2.31-1.28-3.69-1.28c-1.41,0-2.67,0.44-3.76,1.31s-1.8,2-2.11,3.37c-1.1,0.26-2.01,0.84-2.73,1.74
|
||||
S4.61,15.73,4.61,16.88z M11.58,18.4c0,0.24,0.08,0.44,0.24,0.6l2.59,2.61c0.12,0.16,0.32,0.23,0.57,0.23
|
||||
c0.28,0,0.48-0.08,0.61-0.23l2.6-2.61c0.16-0.17,0.24-0.38,0.24-0.6c0-0.23-0.08-0.43-0.24-0.58s-0.36-0.23-0.6-0.23
|
||||
c-0.24,0-0.44,0.08-0.62,0.23l-1.12,1.11v-3.98c0-0.24-0.08-0.43-0.25-0.59c-0.17-0.16-0.37-0.23-0.61-0.23s-0.43,0.08-0.59,0.23
|
||||
c-0.16,0.16-0.23,0.35-0.23,0.59v3.98l-1.1-1.11c-0.18-0.16-0.38-0.23-0.63-0.23c-0.25,0-0.45,0.08-0.61,0.23
|
||||
C11.66,17.97,11.58,18.17,11.58,18.4z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.63,16.91c0,0.66,0.12,1.28,0.38,1.88c0.25,0.6,0.59,1.11,1.02,1.55c0.43,0.43,0.94,0.79,1.53,1.05
|
||||
c0.59,0.27,1.21,0.42,1.87,0.45c0.11,0,0.17-0.06,0.17-0.17v-1.33c0-0.12-0.06-0.19-0.17-0.19c-0.87-0.06-1.6-0.41-2.19-1.03
|
||||
s-0.89-1.36-0.89-2.21c0-0.84,0.28-1.57,0.85-2.19c0.57-0.62,1.26-0.97,2.1-1.04l0.52-0.07c0.13,0,0.2-0.06,0.2-0.17l0.07-0.52
|
||||
c0.07-0.71,0.3-1.36,0.69-1.95c0.39-0.58,0.9-1.04,1.52-1.37s1.29-0.49,2.01-0.49c1.09,0,2.05,0.36,2.86,1.08
|
||||
c0.82,0.72,1.28,1.62,1.39,2.7l0.06,0.57c0,0.12,0.06,0.18,0.19,0.18h1.61c0.9,0,1.67,0.32,2.32,0.97c0.64,0.64,0.97,1.41,0.97,2.3
|
||||
c0,0.85-0.3,1.59-0.89,2.21c-0.59,0.62-1.32,0.97-2.19,1.03c-0.13,0-0.2,0.06-0.2,0.19v1.33c0,0.11,0.07,0.17,0.2,0.17
|
||||
c1.34-0.06,2.47-0.57,3.39-1.51s1.38-2.09,1.38-3.42c0-0.89-0.22-1.72-0.67-2.48c-0.45-0.76-1.05-1.36-1.81-1.8
|
||||
c-0.76-0.44-1.59-0.67-2.48-0.67h-0.32c-0.33-1.33-1.04-2.42-2.11-3.28C16.9,7.82,15.67,7.4,14.3,7.4c-1.41,0-2.66,0.44-3.75,1.33
|
||||
s-1.8,2.01-2.11,3.38c-1.11,0.26-2.02,0.84-2.73,1.74C4.99,14.74,4.63,15.76,4.63,16.91z M10.86,18.18c0,0.74,0.19,1.43,0.56,2.07
|
||||
s0.88,1.14,1.51,1.51c0.63,0.38,1.32,0.56,2.06,0.56c1.15,0,2.13-0.41,2.95-1.22c0.82-0.82,1.23-1.79,1.23-2.92
|
||||
c0-0.23-0.08-0.43-0.25-0.6c-0.17-0.17-0.37-0.25-0.61-0.25c-0.24,0-0.44,0.08-0.61,0.25s-0.26,0.37-0.26,0.6
|
||||
c0,0.67-0.24,1.24-0.72,1.73c-0.48,0.48-1.05,0.72-1.73,0.72c-0.66,0-1.23-0.24-1.71-0.72c-0.48-0.48-0.72-1.06-0.72-1.73
|
||||
c0-0.6,0.18-1.13,0.53-1.6c0.36-0.47,0.79-0.73,1.31-0.77l-0.41,0.39c-0.15,0.15-0.23,0.34-0.23,0.57c0,0.25,0.07,0.47,0.23,0.66
|
||||
c0.14,0.15,0.31,0.23,0.53,0.23c0.22,0.01,0.45-0.07,0.7-0.23l1.82-1.87c0.17-0.17,0.25-0.36,0.25-0.58c0-0.25-0.08-0.45-0.25-0.61
|
||||
l-1.82-1.83c-0.19-0.18-0.39-0.26-0.62-0.26c-0.23,0-0.43,0.08-0.59,0.25c-0.16,0.17-0.24,0.37-0.24,0.61
|
||||
c0,0.24,0.07,0.43,0.23,0.58l0.35,0.36c-1,0.17-1.83,0.63-2.49,1.4C11.19,16.24,10.86,17.14,10.86,18.18z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.64,16.88c0,1.33,0.46,2.48,1.39,3.43c0.93,0.96,2.06,1.47,3.4,1.53c0.11,0,0.17-0.06,0.17-0.17v-1.33
|
||||
c0-0.12-0.06-0.19-0.17-0.19c-0.86-0.04-1.58-0.38-2.18-1.02s-0.9-1.39-0.9-2.25c0-0.82,0.28-1.54,0.84-2.16
|
||||
c0.56-0.61,1.26-0.97,2.1-1.07h0.53c0.13,0,0.2-0.06,0.2-0.18l0.06-0.57c0.11-1.08,0.57-1.99,1.38-2.72s1.77-1.1,2.86-1.1
|
||||
c1.08,0,2.03,0.37,2.85,1.1c0.82,0.73,1.28,1.64,1.39,2.72l0.08,0.57c0,0.12,0.06,0.18,0.18,0.18h1.61c0.89,0,1.66,0.32,2.31,0.96
|
||||
s0.98,1.4,0.98,2.26c0,0.86-0.3,1.61-0.9,2.25c-0.6,0.64-1.33,0.98-2.18,1.02c-0.13,0-0.2,0.06-0.2,0.19v1.33
|
||||
c0,0.11,0.07,0.17,0.2,0.17c0.87-0.02,1.67-0.26,2.4-0.71c0.73-0.45,1.31-1.05,1.73-1.8c0.42-0.75,0.63-1.57,0.63-2.44
|
||||
c0-0.67-0.13-1.31-0.39-1.91c-0.26-0.61-0.62-1.13-1.06-1.57c-0.44-0.44-0.97-0.79-1.58-1.05c-0.61-0.26-1.25-0.39-1.92-0.39h-0.32
|
||||
c-0.33-1.34-1.03-2.43-2.11-3.29c-1.07-0.85-2.3-1.28-3.68-1.28c-1.41,0-2.67,0.44-3.76,1.32s-1.79,2-2.1,3.36
|
||||
c-1.11,0.26-2.02,0.83-2.73,1.73C4.99,14.71,4.64,15.73,4.64,16.88z M11.58,17.51c0,0.25,0.08,0.46,0.24,0.64
|
||||
c0.15,0.15,0.35,0.23,0.61,0.23c0.24,0,0.45-0.08,0.62-0.23l1.11-1.14v3.98c0,0.24,0.08,0.44,0.23,0.61
|
||||
c0.16,0.17,0.35,0.25,0.59,0.25c0.23,0,0.43-0.08,0.6-0.25c0.17-0.17,0.25-0.37,0.25-0.61v-3.94l1.12,1.11
|
||||
c0.4,0.31,0.81,0.31,1.22,0c0.16-0.15,0.24-0.36,0.24-0.62c0-0.24-0.08-0.44-0.24-0.62l-2.59-2.57c-0.16-0.16-0.36-0.24-0.6-0.24
|
||||
c-0.24,0-0.44,0.08-0.59,0.24l-2.58,2.57C11.66,17.08,11.58,17.27,11.58,17.51z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M4.61,16.88c0-1.15,0.36-2.17,1.08-3.07c0.72-0.9,1.63-1.48,2.74-1.73c0.31-1.37,1.02-2.49,2.11-3.37s2.35-1.32,3.76-1.32
|
||||
c1.38,0,2.61,0.43,3.69,1.28s1.78,1.95,2.1,3.29h0.33c0.9,0,1.73,0.22,2.49,0.65s1.37,1.03,1.81,1.79c0.44,0.76,0.67,1.58,0.67,2.48
|
||||
c0,0.88-0.21,1.7-0.63,2.45s-1,1.35-1.73,1.8c-0.73,0.45-1.54,0.69-2.41,0.72H9.41c-1.34-0.06-2.47-0.57-3.4-1.53
|
||||
C5.08,19.37,4.61,18.22,4.61,16.88z M6.32,16.88c0,0.87,0.3,1.62,0.9,2.26s1.33,0.98,2.19,1.03h11.19c0.86-0.04,1.59-0.39,2.19-1.03
|
||||
c0.61-0.64,0.91-1.4,0.91-2.26c0-0.88-0.33-1.63-0.98-2.27c-0.65-0.64-1.42-0.96-2.32-0.96H18.8c-0.11,0-0.17-0.06-0.17-0.18
|
||||
l-0.07-0.57c-0.11-1.08-0.58-1.99-1.4-2.72c-0.82-0.73-1.77-1.1-2.86-1.1c-1.09,0-2.05,0.37-2.85,1.1
|
||||
c-0.81,0.73-1.27,1.64-1.37,2.72l-0.08,0.57c0,0.12-0.07,0.18-0.2,0.18H9.27c-0.84,0.1-1.54,0.46-2.1,1.07S6.32,16.05,6.32,16.88z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M3.62,21.01c0-0.25,0.08-0.46,0.25-0.63c0.17-0.16,0.37-0.24,0.6-0.24h5.42c0.74,0,1.37,0.26,1.89,0.79
|
||||
c0.52,0.53,0.78,1.16,0.78,1.9c0,0.74-0.26,1.38-0.78,1.9c-0.52,0.52-1.15,0.78-1.89,0.78s-1.38-0.26-1.9-0.79
|
||||
c-0.16-0.16-0.23-0.36-0.23-0.6c0-0.24,0.08-0.45,0.23-0.6c0.15-0.16,0.35-0.24,0.6-0.24c0.23,0,0.43,0.08,0.61,0.24
|
||||
c0.2,0.19,0.43,0.29,0.69,0.29s0.49-0.1,0.68-0.29c0.19-0.19,0.29-0.42,0.29-0.7c0-0.26-0.1-0.49-0.29-0.68s-0.42-0.29-0.68-0.29
|
||||
H4.47c-0.23,0-0.43-0.08-0.6-0.25S3.62,21.25,3.62,21.01z M3.62,17.97c0-0.24,0.08-0.45,0.25-0.62c0.17-0.16,0.37-0.24,0.6-0.24
|
||||
h10.55c0.26,0,0.49-0.1,0.68-0.29c0.19-0.19,0.29-0.43,0.29-0.69s-0.1-0.5-0.29-0.69c-0.19-0.19-0.42-0.29-0.68-0.29
|
||||
c-0.28,0-0.5,0.09-0.68,0.28c-0.18,0.15-0.39,0.23-0.64,0.23c-0.24,0-0.44-0.08-0.6-0.23c-0.15-0.15-0.23-0.35-0.23-0.6
|
||||
c0-0.25,0.07-0.45,0.23-0.61c0.51-0.51,1.15-0.76,1.92-0.76c0.74,0,1.38,0.26,1.9,0.78c0.52,0.52,0.78,1.15,0.78,1.88
|
||||
s-0.26,1.37-0.78,1.89c-0.52,0.52-1.15,0.78-1.9,0.78H4.47c-0.24,0-0.44-0.08-0.6-0.24C3.7,18.4,3.62,18.2,3.62,17.97z M5.77,15.61
|
||||
c0,0.08,0.05,0.12,0.16,0.12h1.44c0.08,0,0.15-0.05,0.22-0.15c0.22-0.54,0.58-0.99,1.05-1.35c0.48-0.36,1.01-0.56,1.59-0.6
|
||||
l0.53-0.08c0.13,0,0.2-0.06,0.2-0.17l0.07-0.52c0.11-1.08,0.56-1.99,1.37-2.72s1.76-1.1,2.86-1.1c1.11,0,2.07,0.36,2.88,1.09
|
||||
c0.81,0.73,1.27,1.64,1.39,2.73l0.07,0.59c0,0.11,0.06,0.17,0.17,0.17h1.62c0.91,0,1.68,0.32,2.33,0.96c0.65,0.64,0.97,1.4,0.97,2.3
|
||||
c0,0.89-0.32,1.66-0.97,2.3c-0.65,0.64-1.42,0.96-2.33,0.96h-6.91c-0.12,0-0.19,0.06-0.19,0.17v1.39c0,0.11,0.06,0.17,0.19,0.17
|
||||
h6.91c0.91,0,1.74-0.22,2.51-0.67c0.77-0.44,1.37-1.05,1.82-1.81c0.45-0.77,0.67-1.6,0.67-2.5c0-0.91-0.22-1.74-0.67-2.5
|
||||
c-0.45-0.76-1.05-1.37-1.82-1.81c-0.77-0.44-1.6-0.67-2.51-0.67h-0.31c-0.31-1.33-1.01-2.42-2.1-3.27
|
||||
c-1.08-0.85-2.33-1.27-3.73-1.27c-1.41,0-2.66,0.44-3.75,1.32s-1.78,2-2.07,3.37c-0.86,0.2-1.62,0.61-2.28,1.23
|
||||
s-1.12,1.36-1.38,2.21v0.04C5.77,15.56,5.77,15.58,5.77,15.61z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 22.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 30 30" style="enable-background:new 0 0 30 30;" xml:space="preserve">
|
||||
<path d="M3.1,21.04c0-0.24,0.08-0.45,0.25-0.61s0.38-0.24,0.63-0.24h8.97c0.24,0,0.43,0.08,0.59,0.24c0.16,0.16,0.23,0.36,0.23,0.61
|
||||
c0,0.24-0.08,0.44-0.24,0.6c-0.16,0.16-0.35,0.24-0.59,0.24H3.98c-0.25,0-0.46-0.08-0.63-0.24S3.1,21.27,3.1,21.04z M5.73,17.98
|
||||
c0-0.24,0.09-0.44,0.27-0.6c0.14-0.15,0.34-0.23,0.59-0.23h9c0.23,0,0.42,0.08,0.58,0.23s0.23,0.35,0.23,0.59
|
||||
c0,0.24-0.08,0.44-0.23,0.61c-0.15,0.17-0.35,0.25-0.58,0.25h-9c-0.23,0-0.43-0.09-0.6-0.26S5.73,18.21,5.73,17.98z M6.35,15.65
|
||||
c0,0.09,0.06,0.14,0.17,0.14h1.43c0.09,0,0.17-0.05,0.23-0.14c0.23-0.54,0.57-0.99,1.04-1.35s0.99-0.56,1.58-0.6l0.54-0.07
|
||||
c0.11,0,0.17-0.06,0.17-0.18l0.07-0.52c0.11-1.09,0.58-1.99,1.39-2.72c0.82-0.73,1.77-1.09,2.87-1.09c1.09,0,2.03,0.36,2.83,1.07
|
||||
c0.8,0.72,1.27,1.62,1.41,2.7l0.07,0.59c0,0.11,0.06,0.16,0.18,0.16h1.6c0.91,0,1.68,0.32,2.32,0.96c0.64,0.64,0.96,1.41,0.96,2.32
|
||||
c0,0.88-0.33,1.64-0.97,2.28c-0.65,0.65-1.42,0.97-2.31,0.97h-6.89c-0.12,0-0.18,0.06-0.18,0.17v1.34c0,0.12,0.06,0.18,0.18,0.18
|
||||
h6.89c0.68,0,1.32-0.13,1.94-0.39s1.14-0.61,1.58-1.05s0.79-0.97,1.05-1.58s0.39-1.25,0.39-1.92c0-0.9-0.22-1.73-0.66-2.49
|
||||
c-0.44-0.76-1.04-1.36-1.8-1.8c-0.76-0.44-1.6-0.66-2.5-0.66h-0.31c-0.33-1.34-1.03-2.44-2.1-3.3c-1.08-0.85-2.3-1.28-3.68-1.28
|
||||
c-1.42,0-2.67,0.44-3.76,1.33c-1.09,0.88-1.78,2.01-2.08,3.39c-0.86,0.19-1.62,0.6-2.27,1.21s-1.1,1.35-1.36,2.22v0.02
|
||||
C6.36,15.6,6.35,15.62,6.35,15.65z M7.5,24.13c0-0.24,0.09-0.44,0.26-0.6c0.15-0.16,0.35-0.23,0.59-0.23h8.99
|
||||
c0.24,0,0.45,0.08,0.61,0.24c0.17,0.16,0.25,0.36,0.25,0.6c0,0.24-0.08,0.44-0.25,0.61c-0.17,0.17-0.37,0.25-0.61,0.25H8.35
|
||||
c-0.23,0-0.43-0.08-0.6-0.25C7.58,24.57,7.5,24.37,7.5,24.13z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |