wip improvements but still problematic

This commit is contained in:
terraputix
2026-01-09 21:18:19 +01:00
parent 1e434e0a84
commit 298c8ef597
41 changed files with 572 additions and 423 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"utils": "$lib/utils/ui",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
+5
View File
@@ -0,0 +1,5 @@
{
"userWords": [
"ConfigInterface"
]
}
+1
View File
@@ -8,6 +8,7 @@
"name": "open-meteo-weather",
"version": "0.0.1",
"dependencies": {
"@openmeteo/sdk": "^1.23.0",
"highcharts": "^12.4.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3"
+1
View File
@@ -50,6 +50,7 @@
"vitest-browser-svelte": "^2.0.1"
},
"dependencies": {
"@openmeteo/sdk": "^1.23.0",
"highcharts": "^12.4.0",
"mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3"
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
+1 -1
View File
@@ -20,7 +20,7 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
@@ -2,7 +2,7 @@
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';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -4,7 +4,7 @@
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 { cn, type WithoutChildrenOrChild } from '$lib/utils/ui.js';
import type { ComponentProps } from 'svelte';
let {
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -3,9 +3,9 @@
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 { cn, type WithoutChild } from '$lib/utils/ui.js';
import type { ComponentProps } from 'svelte';
import type { WithoutChildrenOrChild } from '$lib/utils.js';
import type { WithoutChildrenOrChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Select as SelectPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
import type { ComponentProps } from 'svelte';
let {
@@ -1,7 +1,7 @@
<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';
import { cn, type WithoutChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,5 +1,5 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn, type WithElementRef } from '$lib/utils/ui.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
@@ -1,7 +1,7 @@
<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';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<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';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<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';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,7 +1,7 @@
<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';
import { cn, type WithoutChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
@@ -1,6 +1,6 @@
<script lang="ts">
import { Separator as SeparatorPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { cn } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
+1 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { cn, type WithoutChildrenOrChild } from '$lib/utils/ui.js';
let {
ref = $bindable(null),
+123 -20
View File
@@ -1,24 +1,127 @@
import { writable } from 'svelte/store';
import { type Writable, 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, unknown>) {
const { subscribe, set, update } = writable(initialValue);
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { page } from '$app/state';
// 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
import { debounce, isNumeric } from '$lib/utils';
return {
subscribe,
set,
update,
// You might want to add methods to easily update specific hash parameters
updateParam: (key: string, value: unknown) =>
update((current) => ({ ...current, [key]: value }))
import type { Parameters } from '$lib/docs';
export type UrlHashStore = Writable<Parameters>;
export const urlHashStore = (initialValues: Parameters): UrlHashStore => {
const urlHashes: Writable<Parameters> = writable({});
const defaultValues = JSON.parse(JSON.stringify(initialValues));
urlHashes.set(JSON.parse(JSON.stringify(defaultValues)));
function updateURL() {
const searchParams = page.url.searchParams.toString().replaceAll('%2C', ',');
const link = `?${searchParams}${page.url.hash ?? ''}`;
if (page.url.search !== window.location.search) {
goto(link, {
noScroll: true,
keepFocus: true
});
}
}
const processURLParamsUpdate = debounce(() => updateURL());
const updateURLParams = (values: Parameters) => {
if (browser) {
let changedParams = false;
for (const [key, value] of Object.entries(values)) {
let defaultValue = defaultValues[key];
// params key is array
if (defaultValue && Array === defaultValue.constructor) {
if (JSON.stringify(value) === JSON.stringify(defaultValue)) {
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) !== value) {
page.url.searchParams.delete(key);
changedParams = true;
}
} else {
let array = value;
// remove empty string when array has more then 1 values
if (array.length > 1 && array.includes('')) {
array = value.filter((e: string) => e !== '');
}
page.url.searchParams.set(key, array.join(','));
changedParams = true;
}
} else {
let val: number | string = value;
if (isNumeric(defaultValue)) {
defaultValue = Number(defaultValue);
}
if (isNumeric(value)) {
val = Number(value);
}
if (val != defaultValue) {
page.url.searchParams.set(key, String(val));
changedParams = true;
} else {
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) !== val) {
page.url.searchParams.delete(key);
changedParams = true;
}
}
}
if (page.url.searchParams.has(key) && page.url.searchParams.get(key) === '') {
if (
defaultValue === undefined ||
(defaultValue && Array === defaultValue.constructor && defaultValue.length === 0) ||
defaultValue === '0'
) {
page.url.searchParams.delete(key);
changedParams = true;
}
}
}
if (changedParams) {
processURLParamsUpdate();
}
}
};
}
// check if urlParams overrides any default values OR stored values
if (browser && page.url.search) {
for (const [key, value] of page.url.searchParams.entries()) {
let defaultValue = defaultValues[key];
if (defaultValue && defaultValue.constructor === Array) {
if (JSON.stringify(defaultValue) !== JSON.stringify(value)) {
urlHashes.update((urlValues) => {
urlValues[key] = value.split(/,|%2C/);
return urlValues;
});
}
} else {
let val: number | string = value;
if (isNumeric(defaultValue)) {
defaultValue = Number(defaultValue);
}
if (isNumeric(value)) {
val = Number(value);
}
if (defaultValue !== val) {
urlHashes.update((urlValues) => {
urlValues[key] = val;
return urlValues;
});
}
}
}
}
urlHashes.subscribe((values) => {
updateURLParams(values);
});
return urlHashes;
};
+23
View File
@@ -0,0 +1,23 @@
export const isNumeric = (num: string | number) =>
(typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) &&
!isNaN(num as number);
export const pad = (n: string | number) => {
if (n === null || n === undefined) {
return '';
}
return ('0' + n).slice(-2);
};
export function debounce<F extends (...args: unknown[]) => unknown>(
func: F,
timeout = 100
): (this: ThisParameterType<F>, ...args: Parameters<F>) => void {
let timer: ReturnType<typeof setTimeout>;
return function (this: ThisParameterType<F>, ...args: Parameters<F>) {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}
-7
View File
@@ -11,10 +11,3 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
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);
};
-55
View File
@@ -15,57 +15,16 @@
let location = $state(get(storedLocation));
let mounted = $state(false);
let currentWeather = $state(null);
// Subscribe to location changes
storedLocation.subscribe((value) => {
location = value;
if (mounted) {
loadCurrentWeather();
}
});
onMount(() => {
mounted = true;
loadCurrentWeather();
});
const loadCurrentWeather = async () => {
if (!location?.latitude) return;
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${location.latitude}&longitude=${location.longitude}&current=temperature_2m,weather_code&forecast_days=1`
);
const data = await response.json();
currentWeather = data;
} catch (error) {
console.error('Failed to load current weather:', error);
}
};
const getWeatherIcon = (code) => {
const iconMap = {
0: '☀️',
1: '🌤️',
2: '⛅',
3: '☁️',
45: '🌫️',
48: '🌫️',
51: '🌦️',
53: '🌦️',
55: '🌦️',
61: '🌧️',
63: '🌧️',
65: '🌧️',
71: '🌨️',
73: '🌨️',
75: '❄️',
95: '⛈️'
};
return iconMap[code] || '☁️';
};
const getPageTitle = () => {
const path = $page.url.pathname;
if (path.includes('/compare')) return 'Model Comparison';
@@ -129,20 +88,6 @@
</p>
</div>
</div>
<!-- Current Weather -->
{#if currentWeather}
<div class="flex items-center space-x-3" in:fade={{ delay: 800 }}>
<div class="text-center">
<div class="mb-1 text-3xl">
{getWeatherIcon(currentWeather.current.weather_code)}
</div>
<div class="text-2xl font-bold text-gray-900 dark:text-white">
{Math.round(currentWeather.current.temperature_2m)}°C
</div>
</div>
</div>
{/if}
</div>
{/if}
+7 -4
View File
@@ -1,10 +1,13 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement
series: Float32Array | null | undefined
): void => {
if (ctx && series) {
const fillColor = `hsla(${config.styles.mutedForeground}, 0.5)`;
ctx.beginPath();
ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30);
for (const [index, value] of series.entries()) {
@@ -71,8 +74,8 @@ export default (
);
ctx.closePath();
//to fill the space in the shape
ctx.fillStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--muted-foreground').split(' ').join(',')}, 0.5)`;
// USE PRE-CALC STYLE
ctx.fillStyle = fillColor;
ctx.fill();
}
};
+2
View File
@@ -1,3 +1,5 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
+6 -3
View File
@@ -1,15 +1,18 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
series: Float32Array | null | undefined,
canvasElement: HTMLCanvasElement
series: Float32Array | null | undefined
): void => {
if (ctx && series) {
const strokeStyle = `hsla(${config.styles.primary}, 1)`;
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.strokeStyle = strokeStyle;
ctx.lineWidth = 12;
ctx.lineTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY - value ** 0.55 * 45);
+2 -1
View File
@@ -1,3 +1,5 @@
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
@@ -18,7 +20,6 @@ export default (
series[index].getHours() === today.getHours()
) {
// fill now line
// TODO: update this line every minute
ctx.stroke();
ctx.closePath();
ctx.beginPath();
+5 -4
View File
@@ -1,3 +1,4 @@
import type { ConfigInterface } from '../config';
import { getColor } from '../utils/colors';
export default (
@@ -8,10 +9,10 @@ export default (
): 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');
tempGradientFill.addColorStop(0, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.25, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.85, getColor(config.minTemp, unit) + '06');
tempGradientFill.addColorStop(1, getColor(config.minTemp, unit) + '00');
ctx.beginPath();
ctx.moveTo(
-8
View File
@@ -1,8 +0,0 @@
interface ConfigInterface {
maxX: number;
maxY: number;
deltaX: number;
minTemp: number;
maxTemp: number;
diffTemp: number;
}
+13
View File
@@ -0,0 +1,13 @@
export interface ConfigInterface {
maxX: number;
maxY: number;
deltaX: number;
minTemp: number;
maxTemp: number;
diffTemp: number;
styles: {
mutedForeground: string;
primary: string;
border: string;
};
}
+34 -6
View File
@@ -25,19 +25,18 @@ export function rgbToHex(rgb: string) {
return hex;
}
export const getColor = (tempString: string, unit = 'celsius'): string => {
export const getColor = (value: number, unit = 'celsius'): string => {
let index = 0;
const temp = Number(tempString);
if (unit === 'celsius') {
if (temp <= -40) {
if (value <= -40) {
index = 0;
} else if (temp >= 60) {
} else if (value >= 60) {
index = colorScaleHex.length - 1;
} else {
index = temp + 40;
index = value + 40;
}
} else {
const tempInCelsius = Math.round(((temp - 32) * 5) / 9);
const tempInCelsius = Math.round(((value - 32) * 5) / 9);
if (tempInCelsius <= -40) {
index = 0;
} else if (tempInCelsius >= 60) {
@@ -47,5 +46,34 @@ export const getColor = (tempString: string, unit = 'celsius'): string => {
}
}
index = Math.floor(index);
return colorScaleHex[index];
};
export const textWhite = (hex: string): boolean => {
const cleaned = (hex || '').replace('#', '').trim().toLowerCase();
if (!cleaned) {
return true;
}
let r = 0,
g = 0,
b = 0;
if (cleaned.length === 6) {
r = parseInt(cleaned.slice(0, 2), 16);
g = parseInt(cleaned.slice(2, 4), 16);
b = parseInt(cleaned.slice(4, 6), 16);
} else {
throw new Error('Invalid color format');
}
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
return true;
}
// Perceived brightness (YIQ / luma). If brightness is low, use white text.
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
return brightness < 128;
};
+2 -1
View File
@@ -1,4 +1,4 @@
export default {
const map: Record<number, string> = {
0: 'clear',
1: 'clear',
2: 'cloudy',
@@ -79,3 +79,4 @@ export default {
96: 'thunderstorm',
99: 'tornado'
};
export default map;
+323 -289
View File
@@ -1,10 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, tick } from 'svelte';
import { fade } from 'svelte/transition';
import { fetchWeatherApi } from 'openmeteo';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import { storedLocation } from '$lib/stores/settings';
import type { WeatherApiResponse } from '@openmeteo/sdk/weather-api-response';
import type { VariableWithValues } from '@openmeteo/sdk/variable-with-values';
import { urlHashStore } from '$lib/stores/url-hash-store';
import { Label } from '$lib/components/ui/label';
@@ -15,12 +16,52 @@
import precip from '../../canvas/precip';
import raster from '../../canvas/raster';
import tempGradient from '../../canvas/temp-gradient';
import type { ConfigInterface } from '../../config';
import { defaultParameters, models } from '../../options';
import { getColor } from '../../utils/colors';
import { getColor, textWhite } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import { SvelteDate } from 'svelte/reactivity';
import { pad } from '$lib/utils';
// --- Interfaces ---
interface WeatherEntry {
id: number;
name: string;
title: string;
values: string[] | undefined;
}
interface WeatherHourly {
entries: WeatherEntry[];
entriesLength: number;
hourlyTime: Date[];
windDirections: Float32Array | undefined;
indexes: number[];
raw: {
hourlyTemps: Float32Array | undefined;
hourlyCloudCover: Float32Array | undefined;
hourlyPrecip: Float32Array | undefined;
minTemp: number;
};
}
interface WeatherDaily {
time: Date[];
weather_code: VariableWithValues;
temperature_2m_max: VariableWithValues;
temperature_2m_min: VariableWithValues;
sunrise: VariableWithValues;
sunset: VariableWithValues;
sunshine_duration: VariableWithValues;
precipitation_sum: VariableWithValues;
windspeed_10m_max: VariableWithValues;
windgusts_10m_max: VariableWithValues;
winddirection_10m_dominant: VariableWithValues;
}
// --- State Setup ---
const params = urlHashStore({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
@@ -28,266 +69,279 @@
...defaultParameters
});
let location = $state($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let location = $derived($storedLocation);
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();
// UI State
let selectedDay = $state(new Date());
let selectedDayIndex = $state(1);
let scrollDiv: HTMLElement | undefined = $state();
let canvasElement: HTMLCanvasElement | undefined = $state();
let entries = $state(0);
// Data State
let weather: WeatherHourly | null = $state(null);
let weatherDaily: WeatherDaily | null = $state(null);
let diffTemp: number = $state(0);
let maxTemp: number = $state(0);
let weatherCodesHourly: Float32Array | undefined = $state();
let weather = $derived(
(async (location: GeoLocation) => {
const reqParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
// timezone: location.timezone, ???
models: [$params.models],
hourly: [
'precipitation',
'precipitation_probability',
'temperature_2m',
'weather_code',
'windspeed_10m',
'winddirection_10m',
'cloud_cover',
'relative_humidity_2m'
].join(','),
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
const url = 'https://api.open-meteo.com/v1/forecast';
const responses = await fetchWeatherApi(url, reqParams);
const response = responses[0];
const utcOffsetSeconds = response.utcOffsetSeconds();
const hourly = response.hourly()!;
// Constants
const today = new Date();
const entries = 6;
const winddir = true;
weatherCodesHourly = hourly.variables(3)?.valuesArray();
let modelSelected = $derived(models.find((mo) => String(mo.value) === $params.models));
let hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map(
(_, i) =>
new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
);
const hourlyTemps = hourly.variables(2)?.valuesArray();
const hourlyCloudCover = hourly.variables(6)?.valuesArray();
const hourlyPrecip = hourly.variables(0)?.valuesArray();
const indexes = [];
if (hourlyTemps) {
for (const index of hourlyTemps.keys()) {
indexes.push(index);
}
}
// --- Logic ---
const maxX = 10000;
const maxY = 500;
const deltaX = 10000 / hourly.variables(0)?.valuesArray()?.length;
async function loadWeatherData() {
if (!location) return;
const ctx = canvasElement?.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, maxX, maxY);
const commonParams = {
latitude: location.latitude,
longitude: location.longitude,
elevation: location.elevation,
models: [$params.models],
forecast_days: 6,
past_days: 1,
temperature_unit: $params.temperature_unit,
wind_speed_unit: $params.wind_speed_unit,
precipitation_unit: $params.precipitation_unit
};
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 url = 'https://api.open-meteo.com/v1/forecast';
const config: ConfigInterface = {
maxX: maxX,
maxY: maxY,
deltaX: deltaX,
minTemp: minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp
};
try {
const [hourlyRes, dailyRes] = await Promise.all([
fetchWeatherApi(url, {
...commonParams,
hourly:
'precipitation,precipitation_probability,temperature_2m,weather_code,windspeed_10m,winddirection_10m,cloud_cover,relative_humidity_2m'
}),
fetchWeatherApi(url, {
...commonParams,
daily:
'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,sunshine_duration,precipitation_sum,windspeed_10m_max,windgusts_10m_max,winddirection_10m_dominant'
})
]);
// 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);
}
processHourly(hourlyRes[0]);
processDaily(dailyRes[0]);
} catch (e) {
console.error('Weather fetch error:', e);
}
}
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)
);
function processHourly(response: WeatherApiResponse) {
const utcOffsetSeconds = response.utcOffsetSeconds();
const hourly = response.hourly()!;
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()!;
weatherCodesHourly = hourly.variables(3)?.valuesArray() ?? undefined;
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)
);
const hourlyTime = [
...Array((Number(hourly.timeEnd()) - Number(hourly.time())) / hourly.interval())
].map(
(_, i) => new Date((Number(hourly.time()) + i * hourly.interval() + utcOffsetSeconds) * 1000)
);
let winddir = true;
entries = 6;
// Note: The SDK returns Float32Array | null. We use ?? undefined for safer Svelte prop passing
const hourlyTemps = hourly.variables(2)?.valuesArray() ?? undefined;
const hourlyCloudCover = hourly.variables(6)?.valuesArray() ?? undefined;
const hourlyPrecip = hourly.variables(0)?.valuesArray() ?? undefined;
const hourlyPrecipProb = hourly.variables(1)?.valuesArray() ?? undefined;
const hourlyWindSpeed = hourly.variables(4)?.valuesArray() ?? undefined;
const hourlyHumidity = hourly.variables(7)?.valuesArray() ?? undefined;
const hourlyWindDir = hourly.variables(5)?.valuesArray() ?? undefined;
let scrollDiv: HTMLElement = $state();
let tableCells;
const switchDay = (date: SvelteDate, index: number) => {
selectedDay = date;
tableCells = document.querySelectorAll('td.time');
for (let tableCell of tableCells) {
if (Number(tableCell.dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: tableCell.offsetLeft - 110, behavior: 'smooth' });
break;
const indexes: number[] = [];
if (hourlyTemps) {
for (let i = 0; i < hourlyTemps.length; i++) {
indexes.push(i);
}
}
selectedDayIndex = index;
// Calculate Min/Max
// Convert Float32Array to regular array for Math operations, filter NaNs
const tempArray = hourlyTemps ? Array.from(hourlyTemps) : [];
const validTemps = tempArray.filter((t) => !isNaN(t));
const minT = validTemps.length ? Math.min(...validTemps) : 0;
const maxT = validTemps.length ? Math.max(...validTemps) : 0;
maxTemp = maxT;
diffTemp = maxT - minT;
// Map helper
const formatValues = (arr: Float32Array | undefined, digits: number): string[] => {
if (!arr) return [];
return Array.from(arr).map((v) => v.toFixed(digits));
};
weather = {
entries: [
{
id: 0,
name: 'temperature_2m',
title: 'Temperature',
values: formatValues(hourlyTemps, 1)
},
{
id: 1,
name: 'precipitation',
title: 'Precipitation',
values: formatValues(hourlyPrecip, 1)
},
{
id: 2,
name: 'precipitation_probability',
title: 'Precip Prob.',
values: formatValues(hourlyPrecipProb, 0)
},
{
id: 3,
name: 'windspeed_10m',
title: 'Wind',
values: formatValues(hourlyWindSpeed, 0)
},
{
id: 4,
name: 'relative_humidity_2m',
title: 'Rel. Hum.',
values: formatValues(hourlyHumidity, 0)
}
],
entriesLength: hourly.variables(0)?.valuesArray()?.length ?? 0,
hourlyTime: hourlyTime,
windDirections: hourlyWindDir,
indexes: indexes,
raw: {
hourlyTemps,
hourlyCloudCover,
hourlyPrecip,
minTemp: minT
}
};
}
function processDaily(response: WeatherApiResponse) {
const utcOffsetSeconds = response.utcOffsetSeconds();
const daily = response.daily()!;
weatherDaily = {
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)!
};
}
// --- Effects ---
$effect(() => {
if (location && $params) {
loadWeatherData();
}
});
$effect(() => {
if (!weather || !canvasElement) return;
const ctx = canvasElement.getContext('2d');
if (ctx) {
const maxX = 10000;
const maxY = 500;
const deltaX = maxX / (weather.entriesLength || 1); // prevent division by zero
const computedStyle = getComputedStyle(canvasElement);
const styleConfig = {
mutedForeground: computedStyle.getPropertyValue('--muted-foreground').split(' ').join(','),
primary: computedStyle.getPropertyValue('--primary').split(' ').join(','),
border: computedStyle.getPropertyValue('--border').split(' ').join(',')
};
ctx.clearRect(0, 0, maxX, maxY);
const config: ConfigInterface = {
maxX,
maxY,
deltaX,
minTemp: weather.raw.minTemp,
maxTemp: maxTemp,
diffTemp: diffTemp || 1, // prevent division by zero in canvas
styles: styleConfig
};
daylight(ctx, config, weather.hourlyTime);
raster(ctx, config, weather.hourlyTime, today, canvasElement);
tempGradient(ctx, config, weather.raw.hourlyTemps, $params.temperature_unit);
cloudCover(ctx, config, weather.raw.hourlyCloudCover);
precip(ctx, config, weather.raw.hourlyPrecip);
}
});
const switchDay = async (date: Date, index?: number) => {
selectedDay = date;
if (index !== undefined) selectedDayIndex = index;
await tick();
if (!scrollDiv) return;
const tableCells = document.querySelectorAll('td.time');
for (const tableCell of tableCells) {
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({
left: (tableCell as HTMLElement).offsetLeft - 110,
behavior: 'smooth'
});
break;
}
}
};
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 });
if (!scrollDiv) return;
const tableCells = document.querySelectorAll('td.time');
for (const tableCell of tableCells) {
if (Number((tableCell as HTMLElement).dataset['date']) === selectedDay.getDate()) {
scrollDiv.scrollTo({ left: (tableCell as HTMLElement).offsetLeft - 110 });
break;
}
}
}, 150);
document.onkeydown = (e) => {
if (!scrollDiv === document.activeElement || !scrollDiv.contains(document.activeElement)) {
if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) {
let newDate = new SvelteDate();
newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate);
}
if (
!scrollDiv ||
scrollDiv === document.activeElement ||
scrollDiv.contains(document.activeElement)
)
return;
if (e.key === 'ArrowLeft') {
if (selectedDay.getDate() >= today.getDate()) {
let newDate = new SvelteDate(selectedDay);
newDate.setDate(selectedDay.getDate() - 1);
switchDay(newDate);
}
if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new SvelteDate();
newDate.setDate(selectedDay.getDate() + 1);
switchDay(newDate);
}
}
if (e.key === 'ArrowRight') {
if (selectedDay.getDate() <= today.getDate() + 4) {
let newDate = new SvelteDate(selectedDay);
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>
@@ -304,10 +358,10 @@
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)}
{#if weatherDaily}
{#each weatherDaily.time as time, index (index)}
{@const selected = time.getDate() === selectedDay.getDate()}
{#if !isNaN(wd.daily.temperature_2m_max.values(index).toFixed(1))}
{#if !isNaN(weatherDaily.temperature_2m_max.values(index)!)}
<button
style="transition: 300ms; min-width: 13%; {selected ? 'transform: scale(1.025)' : ''}"
class="cursor-pointer"
@@ -337,23 +391,23 @@
<svg class="fill-foreground" width="60px" height="60px">
<use
xlink:href="/images/weather-icons/wi-day-{weatherCodes[
wd.daily.weather_code.values(index)
weatherDaily.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'}`}
class="weather-temp-max flex min-w-16.25 justify-center rounded-t p-1 text-sm"
style={`background-color: ${getColor(weatherDaily.temperature_2m_max.values(index)!, $params.temperature_unit)}; color: ${weatherDaily.temperature_2m_min.values(index)! < ($params.temperature_unit === 'celsius' ? 4 : 7) || weatherDaily.temperature_2m_min.values(index)! >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_max.values(index).toFixed(1)}
{weatherDaily.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'}`}
class="weather-temp-min flex min-w-16.25 justify-center rounded-b p-1 text-sm"
style={`background: ${getColor(weatherDaily.temperature_2m_min.values(index)!, $params.temperature_unit)}; color: ${weatherDaily.temperature_2m_min.values(index)! < ($params.temperature_unit === 'celsius' ? 4 : 7) || weatherDaily.temperature_2m_min.values(index)! >= ($params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
>
{wd.daily.temperature_2m_min.values(index).toFixed(1)}
{weatherDaily.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">
@@ -368,7 +422,7 @@
</div>
</div>
{Number(wd.daily.sunshine_duration.values(index) / 3600).toFixed(0)}h
{Number(weatherDaily.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">
@@ -381,7 +435,7 @@
</svg>
</div>
</div>
{Number(wd.daily.precipitation_sum.values(index)).toFixed(
{Number(weatherDaily.precipitation_sum.values(index)).toFixed(
1
)}{$params.precipitation_unit === 'mm' ? 'mm' : "'"}
</div>
@@ -389,9 +443,9 @@
</button>
{/if}
{/each}
{:catch error}
<p style="color: red">{error.message}</p>
{/await}
{:else}
<p>Loading...</p>
{/if}
</div>
<div class="ml-22 md:ml-0">
<h3 class="text-xl font-bold">
@@ -410,7 +464,7 @@
<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]"
class="w-ful relative -mx-5 overflow-x-scroll overflow-y-hidden md:-ml-27.5"
>
<canvas
bind:this={canvasElement}
@@ -423,7 +477,7 @@
<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}
{#if weather && weather.entries[0] && weather.entries[0].values}
<tr>
<th
scope="row"
@@ -460,13 +514,13 @@
{@const now =
weather.hourlyTime[index].getDate() === today.getDate() &&
weather.hourlyTime[index].getHours() === today.getHours()}
<!-- Safe access to values -->
{@const tempVal = weather.entries[0].values?.[index] ?? '0'}
<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 +
0.54 * 200 * ((maxTemp - Number(tempVal)) / 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">
@@ -475,7 +529,7 @@
xlink:href="/images/weather-icons/wi-{weather.hourlyTime[index].getHours() >
6 && weather.hourlyTime[index].getHours() < 21
? 'day'
: 'night'}-{weatherCodes[weatherCodesHourly[index]]}.svg#Layer_1"
: 'night'}-{weatherCodes[weatherCodesHourly?.[index] ?? 0]}.svg#Layer_1"
></use>
</svg></td
>
@@ -490,7 +544,7 @@
>Temp graph</th
>
{#each weather.indexes as index, j (j)}
{@const temp = weather.entries[0].values[index]}
{@const temp = Number(weather.entries[0].values?.[index])}
{#if !isNaN(temp)}
<td
@@ -519,7 +573,11 @@
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(entry.values[index])}
{@const val = entry.values?.[index]}
{@const valNum = Number(val)}
{@const bgColor = getColor(valNum, $params.temperature_unit)}
{#if val !== undefined && !isNaN(valNum)}
<!-- Note: We use values?.[index] access here. -->
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
@@ -527,41 +585,17 @@
: ''}"
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 === 'temperature_2m' ? 'background: ' + bgColor : ''};
{entry.name === 'temperature_2m' ? ('color: ' + textWhite(bgColor) ? 'white' : 'black') : ''};
{entry.name === 'precipitation_probability'
? 'background: rgba(0, 0, 230,' + valNum / 120 + ')'
: ''};
{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)')
? 'color: ' + (valNum > 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
? 'background: rgba(0, 240, 240,' + valNum ** 3.8 / 10 ** 8.2 + ')'
: ''};">{val}</td
>
{/if}
{/each}
@@ -577,7 +611,7 @@
>Wind Dir.</th
>
{#each weather.indexes as index, j (j)}
{#if !isNaN(weather.windDirections[index])}
{#if weather.windDirections && !isNaN(weather.windDirections[index])}
<td
class="border-r border-border {weather.hourlyTime[index].getDate() ===
today.getDate() && weather.hourlyTime[index].getHours() === today.getHours()
@@ -595,14 +629,14 @@
{/each}
</tr>
{/if}
{/await}
{/if}
</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)}
{#if weatherDaily}
{@const sunrise = new Date(Number(weatherDaily.sunrise.valuesInt64(selectedDayIndex)) * 1000)}
{@const sunset = new Date(Number(weatherDaily.sunset.valuesInt64(selectedDayIndex)) * 1000)}
<div class="mt-6">
<div class="flex items-center gap-1">
<svg class="fill-foreground" width="28px" height="28px">
@@ -616,7 +650,7 @@
Sunset: {pad(sunset.getHours())}:{pad(sunset.getMinutes())}
</div>
</div>
{/await}
{/if}
<div>
<div class="mt-6 flex gap-6 md:mt-12">
<div class="relative w-1/2">