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",
"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
@@ -52,6 +52,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"
@@ -242,8 +242,8 @@
{location.country || ''}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if}
{location.latitude?.toFixed(2)}°N {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p>
</div>
</div>
+18 -18
View File
@@ -1,24 +1,24 @@
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;
id?: number;
name?: string;
latitude?: number;
longitude?: number;
elevation?: number;
feature_code?: string;
country_code?: string;
admin1_id?: number;
admin3_id?: number;
admin4_id?: number;
timezone?: string;
population?: number;
postcodes?: string[];
country_id?: number;
country?: string;
admin1?: string;
admin3?: string;
admin4?: string;
}
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 './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) => {
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);
};
}
+1
View File
@@ -0,0 +1 @@
export const prerender = true;
+4 -3
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import { fade, fly } from 'svelte/transition';
import { storedLocation } from '$lib/stores/settings';
@@ -15,7 +14,7 @@
} from '$lib/components/ui/card';
let mounted = $state(false);
let location = get(storedLocation);
let location = $derived($storedLocation);
onMount(() => {
mounted = true;
@@ -182,7 +181,9 @@
Current Location: {location.name}
</CardTitle>
<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>
</CardHeader>
<CardContent class="text-center">
+2 -2
View File
@@ -134,8 +134,8 @@
{/if}{location.country}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{location.latitude.toFixed(2)}°N, {location.longitude.toFixed(2)}°E
{#if location.elevation}{location.elevation.toFixed(0)}m{/if}
{location.latitude?.toFixed(2)}°N, {location.longitude?.toFixed(2)}°E
{#if location.elevation}{location.elevation?.toFixed(0)}m{/if}
</p>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
+2 -2
View File
@@ -4,6 +4,6 @@ import type { PageLoad } from './$types';
export const prerender = true;
export const load = (async () => {
export const load: PageLoad = async () => {
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 type { LayoutLoad } from './$types';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
+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();
+6 -4
View File
@@ -1,5 +1,7 @@
import { getColor } from '../utils/colors';
import type { ConfigInterface } from '../config';
export default (
ctx: CanvasRenderingContext2D | null | undefined,
config: ConfigInterface,
@@ -8,10 +10,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(
+1 -1
View File
@@ -2,7 +2,7 @@ import { get } from 'svelte/store';
import { storedLocation } from '$lib/stores/settings';
import type { LayoutLoad } from './$types';
import type { LayoutLoad } from '././$types';
const location = get(storedLocation);
+30 -36
View File
@@ -11,13 +11,10 @@
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import { hourly, models as modelsFlat } from '../options';
import { hourly, models } from '../options';
import './highcharts.css';
import { defaultParameters } from './options';
// Wrap models in array to match template expectation of nested arrays like hourly
const models = [modelsFlat];
let node: HTMLElement;
let chart: any;
let Highcharts = $state<typeof import('highcharts') | null>(null);
@@ -338,44 +335,41 @@
<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 || 0}&nbsp;/&nbsp;{models.flat().length}
{params.models?.length || 0}&nbsp;/&nbsp;{models.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 item (item.value)}
{@const { value, label } = item as { value: string; label: string }}
<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 class="mb-3">
{#each models 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>
</div>
<!-- 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;
}
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;
};
+3 -3
View File
@@ -10,9 +10,9 @@ import type { PageLoad } from './$types';
export const prerender = true;
export const load = (async () => {
export const load: PageLoad = async () => {
const location = get(storedLocation);
const locationRoute = geoLocationNameToRoute(location.name);
const locationRoute = geoLocationNameToRoute(location.name || '');
throw redirect(
303,
'/weather/week/' +
@@ -22,4 +22,4 @@ export const load = (async () => {
: locationRoute + '_' + location.id
: locationRoute + '_' + location.id)
);
}) satisfies PageLoad;
};
@@ -21,6 +21,8 @@
import { getColor } from '../../utils/colors';
import weatherCodes from '../../utils/weather-codes';
import type { ConfigInterface } from '../../config';
let params = $state({
latitude: [$storedLocation.latitude],
longitude: [$storedLocation.longitude],
@@ -113,15 +115,20 @@
deltaX: deltaX,
minTemp: minTemp,
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
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!);
cloudCover(ctx, config, hourlyCloudCover);
precip(ctx, config, hourlyPrecip);
}
return {
@@ -348,14 +355,14 @@
</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) ?? 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)}
{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) ?? 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)}
{params.temperature_unit === 'celsius' ? '°C' : '°F'}
@@ -536,7 +543,7 @@
{entry.name === 'temperature_2m'
? 'background: ' +
getColor(
weather.entries[0].values![index].toFixed(0),
Math.round(weather.entries[0].values![index]),
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) {
// 1000 biggest cities
+5 -1
View File
@@ -7,7 +7,11 @@ const config = {
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: { adapter: adapter() }
kit: {
adapter: adapter({
fallback: '404.html'
})
}
};
export default config;