27 changed files with 206 additions and 103 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"userWords": ["ConfigInterface"]
}
+1
View File
@@ -8,6 +8,7 @@
"name": "open-meteo-weather", "name": "open-meteo-weather",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@openmeteo/sdk": "^1.23.0",
"highcharts": "^12.4.0", "highcharts": "^12.4.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3" "openmeteo": "^1.2.3"
+1
View File
@@ -52,6 +52,7 @@
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
}, },
"dependencies": { "dependencies": {
"@openmeteo/sdk": "^1.23.0",
"highcharts": "^12.4.0", "highcharts": "^12.4.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^1.1.0",
"openmeteo": "^1.2.3" "openmeteo": "^1.2.3"
@@ -242,8 +242,8 @@
{location.country || ''} {location.country || ''}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E {location.latitude?.toFixed(2)}°N {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if} {#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p> </p>
</div> </div>
</div> </div>
+18 -18
View File
@@ -1,24 +1,24 @@
import { persisted } from 'svelte-persisted-store'; import { persisted } from 'svelte-persisted-store';
export interface GeoLocation { export interface GeoLocation {
id: number; id?: number;
name: string; name?: string;
latitude: number; latitude?: number;
longitude: number; longitude?: number;
elevation: number; elevation?: number;
feature_code: string; feature_code?: string;
country_code: string | undefined; country_code?: string;
admin1_id: number | undefined; admin1_id?: number;
admin3_id?: number | undefined; admin3_id?: number;
admin4_id?: number | undefined; admin4_id?: number;
timezone: string; timezone?: string;
population: number | undefined; population?: number;
postcodes: string[] | undefined; postcodes?: string[];
country_id: number | undefined; country_id?: number;
country: string | undefined; country?: string;
admin1: string | undefined; admin1?: string;
admin3?: string | undefined; admin3?: string;
admin4?: string | undefined; admin4?: string;
} }
export const defaultLocation: GeoLocation = { export const defaultLocation: GeoLocation = {
+30
View File
@@ -0,0 +1,30 @@
export interface Parameters {
latitude?: number | number[];
longitude?: number | number[];
hourly?: string[];
models?: string[];
daily?: string[];
current?: string[];
minutely_15?: string[];
timezone?: string;
location_mode?: string;
csv_coordinates?: string;
time_mode?: string;
past_days?: string;
forecast_days?: string;
end_date?: string;
start_date?: string;
past_hours?: string;
cell_selection?: string;
forecast_hours?: string;
past_minutely_15?: string;
temporal_resolution?: string;
forecast_minutely_15?: string;
tilt?: string;
azimuth?: string;
timeformat?: string;
wind_speed_unit?: string;
temperature_unit?: string;
precipitation_unit?: string;
[key: string]: any;
}
+17
View File
@@ -1,9 +1,26 @@
export * from './ui.ts'; export * from './ui.ts';
export * from './meteo.ts'; export * from './meteo.ts';
export const isNumeric = (num: string | number) =>
(typeof num === 'number' || (typeof num === 'string' && num.trim() !== '')) &&
!isNaN(num as number);
export const pad = (n: string | number) => { export const pad = (n: string | number) => {
if (n === null || n === undefined) { if (n === null || n === undefined) {
return ''; return '';
} }
return ('0' + n).slice(-2); 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);
};
}
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+4 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
@@ -15,7 +14,7 @@
} from '$lib/components/ui/card'; } from '$lib/components/ui/card';
let mounted = $state(false); let mounted = $state(false);
let location = get(storedLocation); let location = $derived($storedLocation);
onMount(() => { onMount(() => {
mounted = true; mounted = true;
@@ -182,7 +181,9 @@
Current Location: {location.name} Current Location: {location.name}
</CardTitle> </CardTitle>
<CardDescription class="text-center"> <CardDescription class="text-center">
{location.country}{location.latitude.toFixed(2)}°, {location.longitude.toFixed(2)}° {location.country}{location.latitude?.toFixed(2)}°, {location.longitude?.toFixed(
2
)}°
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent class="text-center"> <CardContent class="text-center">
+2 -2
View File
@@ -134,8 +134,8 @@
{/if}{location.country} {/if}{location.country}
</p> </p>
<p class="text-sm text-gray-500 dark:text-gray-400"> <p class="text-sm text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N, {location.longitude.toFixed(2)}°E {location.latitude?.toFixed(2)}°N, {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if} {#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p> </p>
</div> </div>
</div> </div>
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+2 -2
View File
@@ -4,6 +4,6 @@ import type { PageLoad } from './$types';
export const prerender = true; export const prerender = true;
export const load = (async () => { export const load: PageLoad = async () => {
throw redirect(303, '/weather/week/'); throw redirect(303, '/weather/week/');
}) satisfies PageLoad; };
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+7 -4
View File
@@ -1,10 +1,13 @@
import type { ConfigInterface } from '../config';
export default ( export default (
ctx: CanvasRenderingContext2D | null | undefined, ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface, config: ConfigInterface,
series: Float32Array | null | undefined, series: Float32Array | null | undefined
canvasElement: HTMLCanvasElement
): void => { ): void => {
if (ctx && series) { if (ctx && series) {
const fillColor = `hsla(${config.styles.mutedForeground}, 0.5)`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30); ctx.moveTo(0, 35 + (series[0] ** 1.5 / 1000) * 30);
for (const [index, value] of series.entries()) { for (const [index, value] of series.entries()) {
@@ -71,8 +74,8 @@ export default (
); );
ctx.closePath(); ctx.closePath();
//to fill the space in the shape // USE PRE-CALC STYLE
ctx.fillStyle = `hsla(${getComputedStyle(canvasElement).getPropertyValue('--muted-foreground').split(' ').join(',')}, 0.5)`; ctx.fillStyle = fillColor;
ctx.fill(); ctx.fill();
} }
}; };
+2
View File
@@ -1,3 +1,5 @@
import type { ConfigInterface } from '../config';
export default ( export default (
ctx: CanvasRenderingContext2D | null | undefined, ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface, config: ConfigInterface,
+6 -3
View File
@@ -1,15 +1,18 @@
import type { ConfigInterface } from '../config';
export default ( export default (
ctx: CanvasRenderingContext2D | null | undefined, ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface, config: ConfigInterface,
series: Float32Array | null | undefined, series: Float32Array | null | undefined
canvasElement: HTMLCanvasElement
): void => { ): void => {
if (ctx && series) { if (ctx && series) {
const strokeStyle = `hsla(${config.styles.primary}, 1)`;
for (const [index, value] of series.entries()) { for (const [index, value] of series.entries()) {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY); 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.lineWidth = 12;
ctx.lineTo(index * config.deltaX + 0.5 * config.deltaX, config.maxY - value ** 0.55 * 45); 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 ( export default (
ctx: CanvasRenderingContext2D | null | undefined, ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface, config: ConfigInterface,
@@ -18,7 +20,6 @@ export default (
series[index].getHours() === today.getHours() series[index].getHours() === today.getHours()
) { ) {
// fill now line // fill now line
// TODO: update this line every minute
ctx.stroke(); ctx.stroke();
ctx.closePath(); ctx.closePath();
ctx.beginPath(); ctx.beginPath();
+6 -4
View File
@@ -1,5 +1,7 @@
import { getColor } from '../utils/colors'; import { getColor } from '../utils/colors';
import type { ConfigInterface } from '../config';
export default ( export default (
ctx: CanvasRenderingContext2D | null | undefined, ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface, config: ConfigInterface,
@@ -8,10 +10,10 @@ export default (
): void => { ): void => {
if (ctx && series) { if (ctx && series) {
const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY); const tempGradientFill = ctx.createLinearGradient(0, 0, 0, config.maxY);
tempGradientFill.addColorStop(0, getColor(config.maxTemp.toFixed(0), unit) + '5c'); tempGradientFill.addColorStop(0, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.25, getColor(config.maxTemp.toFixed(0), unit) + '5c'); tempGradientFill.addColorStop(0.25, getColor(config.maxTemp, unit) + '5c');
tempGradientFill.addColorStop(0.85, getColor(config.minTemp.toFixed(0), unit) + '06'); tempGradientFill.addColorStop(0.85, getColor(config.minTemp, unit) + '06');
tempGradientFill.addColorStop(1, getColor(config.minTemp.toFixed(0), unit) + '00'); tempGradientFill.addColorStop(1, getColor(config.minTemp, unit) + '00');
ctx.beginPath(); ctx.beginPath();
ctx.moveTo( ctx.moveTo(
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings'; import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types'; import type { LayoutLoad } from '././$types';
const location = get(storedLocation); const location = get(storedLocation);
+30 -36
View File
@@ -11,13 +11,10 @@
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch'; import { Switch } from '$lib/components/ui/switch';
import { hourly, models as modelsFlat } from '../options'; import { hourly, models } from '../options';
import './highcharts.css'; import './highcharts.css';
import { defaultParameters } from './options'; import { defaultParameters } from './options';
// Wrap models in array to match template expectation of nested arrays like hourly
const models = [modelsFlat];
let node: HTMLElement; let node: HTMLElement;
let chart: any; let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null); let Highcharts = $state<typeof import('highcharts') | null>(null);
@@ -338,44 +335,41 @@
<div <div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline" 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 || 0}&nbsp;/&nbsp;{models.flat().length} {params.models?.length || 0}&nbsp;/&nbsp;{models.length}
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<div class="mt-2 grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"> <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">
<div class="mb-3"> {#each models as { value, label } (value)}
{#each group as item (item.value)} <div class="group flex items-center" title={label}>
{@const { value, label } = item as { value: string; label: string }} <Checkbox
<div class="group flex items-center" title={label}> id="{value}_model"
<Checkbox class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
id="{value}_model" {value}
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]" checked={params.models?.includes(value)}
{value} aria-labelledby="{value}_label"
checked={params.models?.includes(value)} onCheckedChange={() => {
aria-labelledby="{value}_label" if (params.models?.includes(value)) {
onCheckedChange={() => { params.models = params.models.filter((item) => {
if (params.models?.includes(value)) { return item !== value;
params.models = params.models.filter((item) => { });
return item !== value; } else if (params.models) {
}); params.models.push(value);
} else if (params.models) { params.models = params.models;
params.models.push(value); }
params.models = params.models; }}
} />
}} <Label
/> id="{value}_model_label"
<Label for="{value}_model"
id="{value}_model_label" class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
for="{value}_model" >
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label </div>
> {/each}
</div> </div>
{/each}
</div>
{/each}
</div> </div>
<!-- HOURLY --> <!-- HOURLY -->
-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; return hex;
} }
export const getColor = (tempString: string, unit = 'celsius'): string => { export const getColor = (value: number, unit = 'celsius'): string => {
let index = 0; let index = 0;
const temp = Number(tempString);
if (unit === 'celsius') { if (unit === 'celsius') {
if (temp <= -40) { if (value <= -40) {
index = 0; index = 0;
} else if (temp >= 60) { } else if (value >= 60) {
index = colorScaleHex.length - 1; index = colorScaleHex.length - 1;
} else { } else {
index = temp + 40; index = value + 40;
} }
} else { } else {
const tempInCelsius = Math.round(((temp - 32) * 5) / 9); const tempInCelsius = Math.round(((value - 32) * 5) / 9);
if (tempInCelsius <= -40) { if (tempInCelsius <= -40) {
index = 0; index = 0;
} else if (tempInCelsius >= 60) { } else if (tempInCelsius >= 60) {
@@ -47,5 +46,34 @@ export const getColor = (tempString: string, unit = 'celsius'): string => {
} }
} }
index = Math.floor(index);
return colorScaleHex[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;
};
+3 -3
View File
@@ -10,9 +10,9 @@ import type { PageLoad } from './$types';
export const prerender = true; export const prerender = true;
export const load = (async () => { export const load: PageLoad = async () => {
const location = get(storedLocation); const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = geoLocationNameToRoute(location.name || '');
throw redirect( throw redirect(
303, 303,
'/weather/week/' + '/weather/week/' +
@@ -22,4 +22,4 @@ export const load = (async () => {
: locationRoute + '_' + location.id : locationRoute + '_' + location.id
: locationRoute + '_' + location.id) : locationRoute + '_' + location.id)
); );
}) satisfies PageLoad; };
@@ -21,6 +21,8 @@
import { getColor } from '../../utils/colors'; import { getColor } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes'; import weatherCodes from '../../utils/weather-codes';
import type { ConfigInterface } from '../../config';
let params = $state({ let params = $state({
latitude: [$storedLocation.latitude], latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude], longitude: [$storedLocation.longitude],
@@ -113,15 +115,20 @@
deltaX: deltaX, deltaX: deltaX,
minTemp: minTemp, minTemp: minTemp,
maxTemp: maxTemp, maxTemp: maxTemp,
diffTemp: diffTemp diffTemp: diffTemp,
styles: {
mutedForeground: '240 3.7% 15.9%',
primary: '222.2 47.4% 11.2%',
border: '214.3 31.8% 91.4%'
}
}; };
// create canvas // create canvas
daylight(ctx, config, hourlyTime); daylight(ctx, config, hourlyTime);
raster(ctx, config, hourlyTime, today, canvasElement!); raster(ctx, config, hourlyTime, today, canvasElement!);
tempGradient(ctx, config, hourlyTemps, params.temperature_unit); tempGradient(ctx, config, hourlyTemps, params.temperature_unit);
cloudCover(ctx, config, hourlyCloudCover, canvasElement!); cloudCover(ctx, config, hourlyCloudCover);
precip(ctx, config, hourlyPrecip, canvasElement!); precip(ctx, config, hourlyPrecip);
} }
return { return {
@@ -348,14 +355,14 @@
</div> </div>
<div <div
class="weather-temp-max flex min-w-[65px] justify-center rounded-t p-1 text-sm" 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) ?? 0).toFixed(0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} style={`background-color: ${getColor(Math.round(wd.daily.temperature_2m_max.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
> >
{wd.daily.temperature_2m_max.values(index)?.toFixed(1)} {wd.daily.temperature_2m_max.values(index)?.toFixed(1)}
{params.temperature_unit === 'celsius' ? '°C' : '°F'} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
</div> </div>
<div <div
class="weather-temp-min flex min-w-[65px] justify-center rounded-b p-1 text-sm" 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) ?? 0).toFixed(0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`} style={`background: ${getColor(Math.round(wd.daily.temperature_2m_min.values(index) ?? 0), params.temperature_unit)}; color: ${(wd.daily.temperature_2m_min.values(index) ?? 0) < (params.temperature_unit === 'celsius' ? 4 : 7) || (wd.daily.temperature_2m_min.values(index) ?? 0) >= (params.temperature_unit === 'celsius' ? 30 : 104) ? 'white' : 'black'}`}
> >
{wd.daily.temperature_2m_min.values(index)?.toFixed(1)} {wd.daily.temperature_2m_min.values(index)?.toFixed(1)}
{params.temperature_unit === 'celsius' ? '°C' : '°F'} {params.temperature_unit === 'celsius' ? '°C' : '°F'}
@@ -536,7 +543,7 @@
{entry.name === 'temperature_2m' {entry.name === 'temperature_2m'
? 'background: ' + ? 'background: ' +
getColor( getColor(
weather.entries[0].values![index].toFixed(0), Math.round(weather.entries[0].values![index]),
params.temperature_unit params.temperature_unit
) )
: ''}; : ''};
+1 -1
View File
@@ -72,7 +72,7 @@ export const load: PageLoad = async (event) => {
} }
} }
const locationRoute = geoLocationNameToRoute(location.name); const locationRoute = geoLocationNameToRoute(location.name || '');
if (location.population && location.population > 543000) { if (location.population && location.population > 543000) {
// 1000 biggest cities // 1000 biggest cities
+5 -1
View File
@@ -7,7 +7,11 @@ const config = {
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
kit: { adapter: adapter() } kit: {
adapter: adapter({
fallback: '404.html'
})
}
}; };
export default config; export default config;