feat: move nav bar to the side (#6)

Co-authored-by: terraputix <terraputix@mailbox.org>
Reviewed-on: useweb/ombrella#6
This commit is contained in:
2026-02-16 01:23:52 +01:00
co-authored by terraputix
parent 53e5fb6538
commit 9a7e66d5d9
29 changed files with 831 additions and 975 deletions
@@ -0,0 +1,352 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { type GeoLocation, storedLocation } from '$lib/stores/settings';
import {
buildAverageSeries,
buildCurrentTimeSeries,
buildDaylightSeries,
buildModelSeries,
calculateAverage,
composeChartOption,
findUnit,
getThemeColors
} from '$lib/utils/echarts';
import { ChartContainer, ChartToolbar, EChart } from '$lib/components/charts';
import '$lib/components/charts/echarts.css';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import { Switch } from '$lib/components/ui/switch';
import {
type MarkArea,
type ModelCompareResult,
fetchModelComparison
} from '$lib/services/weather';
import { hourly, models as modelsFlat } from '../../options';
import { defaultParameters } from '../../options';
import type * as echarts from 'echarts';
const models = [modelsFlat];
// ─── Display State (does NOT trigger data re-fetch) ─────────────────────────
let showLegend = $state(false);
// ─── Data Fetch State ───────────────────────────────────────────────────────
let chartComponents: EChart[] = $state([]);
let chartInstances: echarts.ECharts[] = $state([]);
let chartOptions: Array<Record<string, unknown>> = $state([]);
let mounted = $state(false);
let loading = $state(true);
let location = $state<GeoLocation>($storedLocation);
storedLocation.subscribe((value) => {
location = value;
});
let params = $state({
...defaultParameters,
hourly: ['temperature_2m', 'rain', 'relative_humidity_2m'],
models: [
'ecmwf_ifs025',
'meteofrance_seamless',
'ukmo_seamless',
'icon_seamless',
'gem_seamless'
]
});
// ─── Cached API Response ────────────────────────────────────────────────────
interface FetchedData {
hourly: Record<string, unknown>;
hourly_units: Record<string, string>;
utc_offset_seconds: number;
markAreas: MarkArea[];
timestamps: number[];
}
let fetchedData: FetchedData | null = $state(null);
// ─── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
mounted = true;
});
onDestroy(() => {
chartInstances = [];
chartOptions = [];
chartComponents = [];
});
// ─── Chart Instance Tracking ────────────────────────────────────────────────
function handleChartReady(chart: echarts.ECharts): void {
chartInstances = [...chartInstances, chart];
}
// ─── Data Fetching (only when params.hourly or params.models change) ───────
$effect(() => {
const hourlyVars = params.hourly;
const modelList = params.models;
if (!mounted || !hourlyVars?.length || !modelList?.length) return;
const loc = location;
const loadData = async () => {
loading = true;
chartInstances = [];
chartComponents = [];
const result: ModelCompareResult = await fetchModelComparison({
latitude: loc.latitude!,
longitude: loc.longitude!,
hourlyVariables: hourlyVars,
models: modelList,
temperature_unit: params.temperature_unit as 'celsius' | 'fahrenheit',
wind_speed_unit: params.wind_speed_unit as 'kmh' | 'ms' | 'mph' | 'kn',
precipitation_unit: params.precipitation_unit as 'mm' | 'inch'
});
fetchedData = {
hourly: result.hourlyFlat,
hourly_units: result.hourlyUnitsFlat,
utc_offset_seconds: result.utcOffsetSeconds,
markAreas: result.markAreas,
timestamps: result.timestamps
};
loading = false;
};
loadData();
});
// ─── Chart Option Building (runs when fetchedData OR display toggles change) ─
$effect(() => {
if (!fetchedData) return;
const {
hourly: hourlyData,
hourly_units,
utc_offset_seconds,
markAreas,
timestamps
} = fetchedData;
const _showLegend = showLegend;
const colors = getThemeColors();
const variableCount = params.hourly?.length || 0;
const timeLength = timestamps.length;
const newOptions: Array<Record<string, unknown>> = [];
for (let vi = 0; vi < variableCount; vi++) {
const variable = params.hourly![vi];
const unit = findUnit(hourly_units, hourlyData, variable);
const series: Array<Record<string, unknown>> = [];
for (const [model, values] of Object.entries(hourlyData)) {
if (model === 'time') continue;
if (!model.startsWith(variable)) continue;
const seriesData = (values as (number | null)[]).map(
(val, idx) => [timestamps[idx], val] as [number, number | null]
);
series.push(
buildModelSeries({
name: model,
data: seriesData,
unit
})
);
}
const { average } = calculateAverage(hourlyData, variable, timeLength);
const averageData = average.map((val, idx) => [timestamps[idx], val] as [number, number]);
series.push(buildAverageSeries({ variable, data: averageData, unit }));
series.push(buildCurrentTimeSeries({ utcOffsetSeconds: utc_offset_seconds }));
const daylightSeries = buildDaylightSeries({ markAreas });
if (daylightSeries) {
series.push(daylightSeries);
}
const isFirst = vi === 0;
const isLast = vi === variableCount - 1;
const option = composeChartOption({
title: isFirst
? {
text: 'Model Compare',
subtext: `Compare ${params.hourly?.join(', ') || ''} in models: ${params.models?.join(', ') || ''}`
}
: null,
tooltip: { unit },
legend: { show: _showLegend },
grid: {
hasTitle: isFirst,
hasSubtitle: isFirst,
showLegend: _showLegend
},
yAxis: { unit },
series,
toolbox: false,
showCredit: isLast,
colors
});
newOptions.push(option);
}
chartOptions = newOptions;
});
</script>
<!-- ─── Chart Area ─────────────────────────────────────────────────────────── -->
<ChartContainer
{loading}
chartCount={params.hourly?.length || 0}
chartHeight={showLegend ? 400 : 300}
>
{#each chartOptions as option, i (i)}
<EChart
{option}
height={showLegend ? '400px' : '300px'}
onChartReady={handleChartReady}
bind:this={chartComponents[i]}
/>
{/each}
</ChartContainer>
<!-- ─── Toolbar: Controls + Download ───────────────────────────────────────── -->
<div class="mt-6 md:mt-10">
<ChartToolbar charts={chartInstances} fileName="model-comparison">
{#snippet controls()}
<div class="flex gap-2">
<Switch id="show_legend" name="Show legend" bind:checked={showLegend} />
<Label for="show_legend" class="mb-0.5 cursor-pointer text-lg">Show legend</Label>
</div>
{/snippet}
</ChartToolbar>
</div>
<!-- ─── Models Selection ───────────────────────────────────────────────────── -->
<div class="mt-4 md:mt-8">
<div class="flex">
<a href="#models"><h2 id="models" class="text-2xl md:text-3xl">Models</h2></a>
{#if params.models && params.models.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<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}
</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 = [...params.models, value];
}
}}
/>
<Label
id="{value}_model_label"
for="{value}_model"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
<!-- ─── Hourly Variables Selection ─────────────────────────────────────── -->
<div class="mt-6 md:mt-12">
<div class="flex">
<a href="#hourly_weather_variables"
><h2 id="hourly_weather_variables" class="text-2xl md:text-3xl">
Hourly Weather Variables
</h2></a
>
{#if params.hourly && params.hourly.length > 0}
<div transition:fade={{ duration: 200 }} class="relative mt-1.25">
<div
class="absolute -top-1 ml-2 rounded-full border-2 border-foreground/25 bg-secondary px-3 py-1 text-sm no-underline"
>
{params.hourly?.length || 0}&nbsp;/&nbsp;{hourly.flat().length}
</div>
</div>
{/if}
</div>
<div
class="mt-2 grid grid-flow-row gap-x-2 gap-y-2 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4"
>
{#each hourly as group, i (i)}
<div>
{#each group as { value, label } (value)}
<div class="group flex items-center" title={label}>
<Checkbox
id="{value}_hourly"
class="border-border-dark cursor-pointer bg-muted/50 duration-100 group-hover:border-[currentColor]"
{value}
checked={params.hourly?.includes(value)}
aria-labelledby="{value}_label"
onCheckedChange={() => {
if (params.hourly?.includes(value)) {
params.hourly = params.hourly.filter((item) => {
return item !== value;
});
} else if (params.hourly) {
params.hourly = [...params.hourly, value];
}
}}
/>
<Label
id="{value}_label"
for="{value}_hourly"
class="cursor-pointer truncate py-[0.1rem] pl-[0.42rem]">{label}</Label
>
</div>
{/each}
</div>
{/each}
</div>
</div>
</div>