feat: model comparison picto timeline
This commit is contained in:
@@ -222,6 +222,8 @@ export interface ModelCompareResult {
|
|||||||
utcOffsetSeconds: number;
|
utcOffsetSeconds: number;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
units: Record<string, string>;
|
units: Record<string, string>;
|
||||||
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
/** Flat record compatible with the existing chart utilities (keys like "temperature_2m_icon_seamless") */
|
||||||
hourlyFlat: Record<string, number[]>;
|
hourlyFlat: Record<string, number[]>;
|
||||||
@@ -408,12 +410,14 @@ export async function fetchModelComparison(
|
|||||||
|
|
||||||
// Extract sunrise/sunset from the first response's daily block
|
// Extract sunrise/sunset from the first response's daily block
|
||||||
let markAreas: MarkArea[] = [];
|
let markAreas: MarkArea[] = [];
|
||||||
|
let sunrise: number[] = [];
|
||||||
|
let sunset: number[] = [];
|
||||||
const dailyBlock = firstResponse.daily();
|
const dailyBlock = firstResponse.daily();
|
||||||
if (dailyBlock) {
|
if (dailyBlock) {
|
||||||
const sunriseVar = dailyBlock.variables(0)!;
|
const sunriseVar = dailyBlock.variables(0)!;
|
||||||
const sunsetVar = dailyBlock.variables(1)!;
|
const sunsetVar = dailyBlock.variables(1)!;
|
||||||
const sunrise = getInt64Values(sunriseVar);
|
sunrise = getInt64Values(sunriseVar);
|
||||||
const sunset = getInt64Values(sunsetVar);
|
sunset = getInt64Values(sunsetVar);
|
||||||
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
markAreas = buildDaylightMarkAreas(sunrise, sunset);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -471,6 +475,8 @@ export async function fetchModelComparison(
|
|||||||
utcOffsetSeconds,
|
utcOffsetSeconds,
|
||||||
timezone,
|
timezone,
|
||||||
markAreas,
|
markAreas,
|
||||||
|
sunrise,
|
||||||
|
sunset,
|
||||||
units,
|
units,
|
||||||
hourlyFlat,
|
hourlyFlat,
|
||||||
hourlyUnitsFlat
|
hourlyUnitsFlat
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
|
|
||||||
import { hourly, models as modelsFlat } from '../../options';
|
import { hourly, models as modelsFlat } from '../../options';
|
||||||
import { defaultParameters } from '../../options';
|
import { defaultParameters } from '../../options';
|
||||||
|
import ModelPictogramTimeline from './ModelPictogramTimeline.svelte';
|
||||||
|
|
||||||
import type * as echarts from 'echarts';
|
import type * as echarts from 'echarts';
|
||||||
|
|
||||||
@@ -79,6 +80,8 @@
|
|||||||
timezone: string;
|
timezone: string;
|
||||||
markAreas: MarkArea[];
|
markAreas: MarkArea[];
|
||||||
timestamps: number[];
|
timestamps: number[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let fetchedData: FetchedData | null = $state(null);
|
let fetchedData: FetchedData | null = $state(null);
|
||||||
@@ -119,7 +122,7 @@
|
|||||||
const result: ModelCompareResult = await fetchModelComparison({
|
const result: ModelCompareResult = await fetchModelComparison({
|
||||||
latitude: loc.latitude!,
|
latitude: loc.latitude!,
|
||||||
longitude: loc.longitude!,
|
longitude: loc.longitude!,
|
||||||
hourlyVariables: hourlyVars,
|
hourlyVariables: [...new Set([...hourlyVars, 'weather_code'])],
|
||||||
models: modelList,
|
models: modelList,
|
||||||
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
|
||||||
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
|
||||||
@@ -132,7 +135,9 @@
|
|||||||
hourly_units: result.hourlyUnitsFlat,
|
hourly_units: result.hourlyUnitsFlat,
|
||||||
timezone: result.timezone,
|
timezone: result.timezone,
|
||||||
markAreas: result.markAreas,
|
markAreas: result.markAreas,
|
||||||
timestamps: result.timestamps
|
timestamps: result.timestamps,
|
||||||
|
sunrise: result.sunrise,
|
||||||
|
sunset: result.sunset
|
||||||
};
|
};
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
@@ -150,12 +155,13 @@
|
|||||||
const _showLegend = showLegend;
|
const _showLegend = showLegend;
|
||||||
|
|
||||||
const colors = getThemeColors();
|
const colors = getThemeColors();
|
||||||
const variableCount = params.hourly?.length || 0;
|
const chartVariables = params.hourly?.filter((v) => v !== 'weather_code') || [];
|
||||||
|
const variableCount = chartVariables.length;
|
||||||
const timeLength = timestamps.length;
|
const timeLength = timestamps.length;
|
||||||
const newOptions: Array<Record<string, unknown>> = [];
|
const newOptions: Array<Record<string, unknown>> = [];
|
||||||
|
|
||||||
for (let vi = 0; vi < variableCount; vi++) {
|
for (let vi = 0; vi < variableCount; vi++) {
|
||||||
const variable = params.hourly![vi];
|
const variable = chartVariables[vi];
|
||||||
const unit = findUnit(hourly_units, hourlyData, variable);
|
const unit = findUnit(hourly_units, hourlyData, variable);
|
||||||
|
|
||||||
const series: Array<Record<string, unknown>> = [];
|
const series: Array<Record<string, unknown>> = [];
|
||||||
@@ -195,7 +201,7 @@
|
|||||||
title: isFirst
|
title: isFirst
|
||||||
? {
|
? {
|
||||||
text: 'Model Compare',
|
text: 'Model Compare',
|
||||||
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
subtext: `Compare ${chartVariables.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
tooltip: { unit, timezone },
|
tooltip: { unit, timezone },
|
||||||
@@ -222,11 +228,7 @@
|
|||||||
|
|
||||||
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
|
||||||
|
|
||||||
<ChartContainer
|
<ChartContainer {loading} chartCount={chartOptions.length} chartHeight={showLegend ? 400 : 300}>
|
||||||
{loading}
|
|
||||||
chartCount={params.hourly?.length || 0}
|
|
||||||
chartHeight={showLegend ? 400 : 300}
|
|
||||||
>
|
|
||||||
{#each chartOptions as option, i (i)}
|
{#each chartOptions as option, i (i)}
|
||||||
<EChart
|
<EChart
|
||||||
{option}
|
{option}
|
||||||
@@ -237,6 +239,17 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|
||||||
|
{#if fetchedData && !loading}
|
||||||
|
<ModelPictogramTimeline
|
||||||
|
timestamps={fetchedData.timestamps}
|
||||||
|
hourlyFlat={fetchedData.hourly as Record<string, number[]>}
|
||||||
|
models={params.models || []}
|
||||||
|
sunrise={fetchedData.sunrise}
|
||||||
|
sunset={fetchedData.sunset}
|
||||||
|
timezone={fetchedData.timezone}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
|
||||||
|
|
||||||
<div class="mt-6 md:mt-10">
|
<div class="mt-6 md:mt-10">
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { formatZoned, getZonedHour } from '$lib/utils/date';
|
||||||
|
|
||||||
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
timestamps: number[];
|
||||||
|
hourlyFlat: Record<string, number[]>;
|
||||||
|
models: string[];
|
||||||
|
sunrise: number[];
|
||||||
|
sunset: number[];
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { timestamps, hourlyFlat, models, sunrise, sunset, timezone }: Props = $props();
|
||||||
|
|
||||||
|
let hourlyInterval = $state<1 | 3>(3);
|
||||||
|
|
||||||
|
let filteredIndices = $derived(
|
||||||
|
timestamps.reduce<number[]>((acc, ts, i) => {
|
||||||
|
if (hourlyInterval === 1 || getZonedHour(new Date(ts), timezone) % 3 === 0) {
|
||||||
|
acc.push(i);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
);
|
||||||
|
|
||||||
|
function checkNewDay(i: number, ts: number): boolean {
|
||||||
|
if (i === 0) return false;
|
||||||
|
const prevTs = timestamps[filteredIndices[i - 1]];
|
||||||
|
return (
|
||||||
|
formatZoned(new Date(ts), timezone, 'd') !== formatZoned(new Date(prevTs), timezone, 'd')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if it's daytime for each timestamp based on sunrise/sunset data.
|
||||||
|
*/
|
||||||
|
let allDaytimeFlags = $derived(
|
||||||
|
timestamps.map((ts) => {
|
||||||
|
const tsS = ts / 1000;
|
||||||
|
for (let i = 0; i < sunrise.length; i++) {
|
||||||
|
const s = sunrise[i];
|
||||||
|
const e = sunset[i];
|
||||||
|
// Between sunrise and sunset of the same day
|
||||||
|
if (tsS >= s && tsS < e) return true;
|
||||||
|
// Between sunset of day i and sunrise of day i+1 (night)
|
||||||
|
const nextS = sunrise[i + 1] || Infinity;
|
||||||
|
if (tsS >= e && tsS < nextS) return false;
|
||||||
|
}
|
||||||
|
// Fallback: before the first sunrise
|
||||||
|
if (sunrise.length > 0 && tsS < sunrise[0]) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters models that actually have weather_code data available in the response.
|
||||||
|
*/
|
||||||
|
let displayModels = $derived(models.filter((m) => hourlyFlat[`weather_code_${m}`]));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet weatherIcon(name: string, size: number = 24)}
|
||||||
|
<svg class="inline-block fill-foreground" width={size} height={size}>
|
||||||
|
<use xlink:href="/images/weather-icons/{name}.svg#Layer_1"></use>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if displayModels.length > 0}
|
||||||
|
<div class="mt-8">
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold">Model Comparison Timeline</h3>
|
||||||
|
<div class="flex items-center gap-1.5 text-[13px] font-semibold">
|
||||||
|
<span class="select-none text-muted-foreground">3h</span>
|
||||||
|
<button
|
||||||
|
class="relative h-6 w-11 cursor-pointer rounded-full border transition-colors
|
||||||
|
{hourlyInterval === 1 ? 'border-primary/40 bg-primary' : 'border-border bg-muted'}"
|
||||||
|
onclick={() => (hourlyInterval = hourlyInterval === 1 ? 3 : 1)}
|
||||||
|
title="Toggle between 1-hour and 3-hour intervals"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="absolute top-0.75 size-4.5 rounded-full bg-white shadow-sm transition-[left] duration-200
|
||||||
|
{hourlyInterval === 1 ? 'left-5.5' : 'left-0.75'}"
|
||||||
|
></span>
|
||||||
|
</button>
|
||||||
|
<span class="select-none text-muted-foreground">1h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="overflow-x-auto rounded-lg border border-border bg-card shadow-sm"
|
||||||
|
style="scrollbar-width: thin"
|
||||||
|
>
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-muted/30">
|
||||||
|
<th
|
||||||
|
class="sticky left-0 z-20 w-32 border-b border-r border-border bg-muted/95 p-2 text-left text-xs font-bold"
|
||||||
|
>
|
||||||
|
Model
|
||||||
|
</th>
|
||||||
|
{#each filteredIndices as idx, i}
|
||||||
|
{@const ts = timestamps[idx]}
|
||||||
|
{@const isNewDay = checkNewDay(i, ts)}
|
||||||
|
<th
|
||||||
|
class="min-w-11 border-b border-r border-border/50 p-2 text-center text-[10px] {isNewDay
|
||||||
|
? 'border-l-2 border-l-primary/30'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<div class="font-bold">{formatZoned(new Date(ts), timezone, 'HH')}</div>
|
||||||
|
<div class="text-muted-foreground">
|
||||||
|
{isNewDay ? formatZoned(new Date(ts), timezone, 'EEE d') : ''}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each displayModels as model}
|
||||||
|
<tr class="group hover:bg-muted/10">
|
||||||
|
<td
|
||||||
|
class="sticky left-0 z-10 border-b border-r border-border bg-card p-2 text-[11px] font-semibold group-hover:bg-muted/20"
|
||||||
|
>
|
||||||
|
{model.replace(/_/g, ' ')}
|
||||||
|
</td>
|
||||||
|
{#each filteredIndices as idx, i}
|
||||||
|
{@const ts = timestamps[idx]}
|
||||||
|
{@const codes = hourlyFlat[`weather_code_${model}`]}
|
||||||
|
{@const code = codes ? codes[idx] : 0}
|
||||||
|
{@const day = allDaytimeFlags[idx]}
|
||||||
|
{@const isNewDay = checkNewDay(i, ts)}
|
||||||
|
<td
|
||||||
|
class="border-b border-r border-border/30 p-1.5 text-center {isNewDay
|
||||||
|
? 'border-l-2 border-l-primary/20'
|
||||||
|
: ''} {!day ? 'bg-indigo-950/5 dark:bg-indigo-500/5' : ''}"
|
||||||
|
>
|
||||||
|
{@render weatherIcon(getWeatherIconName(code, day))}
|
||||||
|
</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,4 +80,10 @@ const weatherCodes: Record<number, string> = {
|
|||||||
99: 'tornado'
|
99: 'tornado'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export function getWeatherIconName(code: number, daytime: boolean): string {
|
||||||
|
const prefix = daytime ? 'day' : 'night';
|
||||||
|
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
||||||
|
return `wi-${prefix}-${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default weatherCodes;
|
export default weatherCodes;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
import { formatUtcOffset, formatZoned, getZonedHour, isSameDayInZone } from '$lib/utils/date';
|
||||||
|
|
||||||
import { getTempStyle } from '../../utils/colors';
|
import { getTempStyle } from '../../utils/colors';
|
||||||
import weatherCodes from '../../utils/weather-codes';
|
import { getWeatherIconName } from '../../utils/weather-codes';
|
||||||
import {
|
import {
|
||||||
type FetchedDaily,
|
type FetchedDaily,
|
||||||
type FetchedHourly,
|
type FetchedHourly,
|
||||||
@@ -159,12 +159,6 @@
|
|||||||
let sunsetPercent = $derived(
|
let sunsetPercent = $derived(
|
||||||
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
sunTimes && cellData.length > 0 ? timeToFraction(sunTimes.sunset) * 100 : null
|
||||||
);
|
);
|
||||||
|
|
||||||
function getWeatherIconName(code: number, daytime: boolean): string {
|
|
||||||
const prefix = daytime ? 'day' : 'night';
|
|
||||||
const name = weatherCodes[code as keyof typeof weatherCodes] ?? 'clear';
|
|
||||||
return `wi-${prefix}-${name}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet weatherIcon(name: string, size: number = 16)}
|
{#snippet weatherIcon(name: string, size: number = 16)}
|
||||||
|
|||||||
Reference in New Issue
Block a user