revamp weather UI: cards, meteograms, units in header, location favorites and range controls
This commit is contained in:
@@ -86,6 +86,21 @@
|
||||
delete groups[name];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the shared crosshair of a chart group from the outside (e.g. hovering
|
||||
* the hourly table). `time` is epoch seconds, or null to clear. No-op if no
|
||||
* chart in that group is currently mounted.
|
||||
*/
|
||||
export function setGroupHover(name: string, time: number | null): void {
|
||||
const state = groups[name];
|
||||
if (state) state.hover = time;
|
||||
}
|
||||
|
||||
/** Current shared zoom range of a group (null = full range), reactive. */
|
||||
export function groupRange(name: string): { start: number; end: number } | null {
|
||||
return groups[name]?.range ?? null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
@@ -109,6 +124,8 @@
|
||||
bands?: { start: number; end: number }[];
|
||||
/** Weather pictograms drawn across the top (t in epoch seconds) */
|
||||
pictograms?: { t: number; icon: string }[];
|
||||
/** Wind-direction arrows drawn across the top (t in epoch seconds, deg from N) */
|
||||
windArrows?: { t: number; deg: number }[];
|
||||
/** Highlighted time range (epoch seconds), e.g. the selected day */
|
||||
highlight?: { start: number; end: number };
|
||||
/** Unit label for the left y axis (also used in tooltip values) */
|
||||
@@ -131,6 +148,12 @@
|
||||
yMaxRight?: number;
|
||||
/** Invert the right axis (min at the top) */
|
||||
invertRight?: boolean;
|
||||
/** Reserve the right-axis gutter even without a right axis (keeps a row of
|
||||
* stacked charts identically sized) */
|
||||
reserveRightAxis?: boolean;
|
||||
/** Reserve this many top icon-rows even if this chart has fewer, so a row
|
||||
* of stacked charts share the same plot rectangle */
|
||||
reserveTopRows?: number;
|
||||
/** Chart title drawn top-left on the canvas */
|
||||
title?: string;
|
||||
/** Smaller subtitle drawn under the title */
|
||||
@@ -151,6 +174,7 @@
|
||||
series,
|
||||
bands = [],
|
||||
pictograms = [],
|
||||
windArrows = [],
|
||||
highlight,
|
||||
unit = '',
|
||||
unitRight,
|
||||
@@ -162,6 +186,8 @@
|
||||
yMinRight,
|
||||
yMaxRight,
|
||||
invertRight = false,
|
||||
reserveRightAxis = false,
|
||||
reserveTopRows = 0,
|
||||
title,
|
||||
subtitle,
|
||||
showLegend = false,
|
||||
@@ -175,6 +201,10 @@
|
||||
const PAD_BOTTOM = 34;
|
||||
const MIN_SPAN = 2 * 3600; // minimum zoom window: 2 hours
|
||||
const HOUR = 3600;
|
||||
// Top icon rows (weather pictograms / wind arrows)
|
||||
const ICON_ROW_H = 30; // reserved height per icon row
|
||||
const ICON_BAND_H = 28; // visible band height
|
||||
const ICON_PX = 26; // pictogram size
|
||||
// Puffy cloud band: 100% cover hangs 40px from the top of the plot
|
||||
const CLOUD_BAND_MAX = 40;
|
||||
|
||||
@@ -186,15 +216,8 @@
|
||||
let themeVersion = $state(0);
|
||||
const legendHidden = new SvelteSet<string>();
|
||||
|
||||
// Briefly shown when the user scrolls over the chart without holding Ctrl
|
||||
let zoomHintVisible = $state(false);
|
||||
let zoomHintTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function showZoomHint(): void {
|
||||
zoomHintVisible = true;
|
||||
clearTimeout(zoomHintTimeout);
|
||||
zoomHintTimeout = setTimeout(() => (zoomHintVisible = false), 1200);
|
||||
}
|
||||
// Drag-to-zoom selection rectangle (plot-local pixel x), null when inactive.
|
||||
let dragSelect = $state<{ x0: number; x1: number } | null>(null);
|
||||
|
||||
// Zoom range and crosshair: either group-shared or local to this chart.
|
||||
// The group is acquired once at component init (the prop is treated as fixed).
|
||||
@@ -205,7 +228,6 @@
|
||||
|
||||
onDestroy(() => {
|
||||
if (groupName) releaseGroup(groupName);
|
||||
clearTimeout(zoomHintTimeout);
|
||||
});
|
||||
|
||||
// ─── Derived: view window & scales ──────────────────────────────────────────
|
||||
@@ -230,9 +252,14 @@
|
||||
|
||||
// Tighter left gutter on narrow screens so axis labels sit near the edge
|
||||
let padLeft = $derived(width > 0 && width < 520 ? 38 : 60);
|
||||
let padRight = $derived(hasRightAxis ? 56 : 20);
|
||||
// Reserve a slim row at the very top for weather pictograms when present
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + (pictograms.length > 0 ? 22 : 0));
|
||||
// Reserve the right gutter when this chart (or a sibling, via reserveRightAxis)
|
||||
// has a right axis, so a stacked row of charts share the same plot width.
|
||||
let padRight = $derived(hasRightAxis || reserveRightAxis ? 56 : 20);
|
||||
// Icon rows across the top: pictograms and/or wind arrows. reserveTopRows keeps
|
||||
// a stacked row of charts the same height even if some have fewer icon rows.
|
||||
let ownIconRows = $derived((pictograms.length > 0 ? 1 : 0) + (windArrows.length > 0 ? 1 : 0));
|
||||
let iconRows = $derived(Math.max(ownIconRows, reserveTopRows));
|
||||
let padTop = $derived((title ? (subtitle ? 66 : 46) : 28) + iconRows * ICON_ROW_H);
|
||||
let plotW = $derived(Math.max(1, width - padLeft - padRight));
|
||||
let plotH = $derived(Math.max(1, height - padTop - PAD_BOTTOM));
|
||||
|
||||
@@ -408,21 +435,50 @@
|
||||
|
||||
// ─── Pictograms (DOM overlay across the top) ─────────────────────────────────
|
||||
|
||||
// Thin the icons so they never crowd: keep ≥ 34px apart within the view.
|
||||
// The icon band spans exactly the plot area so the icons line up with the
|
||||
// data (and axis) below and never overhang the plot's cut-off edge.
|
||||
let iconBandLeft = $derived(padLeft);
|
||||
let iconBandWidth = $derived(plotW);
|
||||
function iconBandX(t: number): number {
|
||||
return xPix(t) - padLeft;
|
||||
}
|
||||
|
||||
// Thin the icons so they never crowd: keep ≥ 40px apart within the band.
|
||||
let visiblePictograms = $derived.by((): { x: number; icon: string }[] => {
|
||||
if (pictograms.length === 0 || width <= 0) return [];
|
||||
const out: { x: number; icon: string }[] = [];
|
||||
let lastX = -Infinity;
|
||||
for (const p of pictograms) {
|
||||
if (p.t < viewStart || p.t > viewEnd) continue;
|
||||
const x = xPix(p.t);
|
||||
if (x - lastX < 34) continue;
|
||||
const x = iconBandX(p.t);
|
||||
if (x - lastX < 40) continue;
|
||||
out.push({ x, icon: p.icon });
|
||||
lastX = x;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Same thinning for the wind-direction arrow row.
|
||||
let visibleWindArrows = $derived.by((): { x: number; deg: number }[] => {
|
||||
if (windArrows.length === 0 || width <= 0) return [];
|
||||
const out: { x: number; deg: number }[] = [];
|
||||
let lastX = -Infinity;
|
||||
for (const a of windArrows) {
|
||||
if (a.t < viewStart || a.t > viewEnd) continue;
|
||||
const x = iconBandX(a.t);
|
||||
if (x - lastX < 40) continue;
|
||||
out.push({ x, deg: a.deg });
|
||||
lastX = x;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
// Vertical offset (px from container top) of each icon row's top edge, anchored
|
||||
// just above the plot. When both rows are present, pictograms sit above the
|
||||
// wind arrows (which stay closest to the plot).
|
||||
let pictoRowTop = $derived(padTop - (windArrows.length > 0 ? 2 : 1) * ICON_ROW_H + 2);
|
||||
let windRowTop = $derived(padTop - ICON_ROW_H + 2);
|
||||
|
||||
// ─── Local minima / maxima (for value labels) ────────────────────────────────
|
||||
|
||||
function findExtrema(data: (number | null)[]): { i: number; type: 'min' | 'max' }[] {
|
||||
@@ -760,6 +816,8 @@
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = bgColor;
|
||||
ctx.fillStyle = strongColor;
|
||||
// clear the (possibly thick) line + its outline before the text sits
|
||||
const off = (s.width ?? 2) / 2 + 7;
|
||||
for (const ext of findExtrema(s.data)) {
|
||||
const t = timestamps[ext.i];
|
||||
if (t < viewStart || t > viewEnd) continue;
|
||||
@@ -767,9 +825,12 @@
|
||||
const x = xPix(t);
|
||||
const y = yPix(v, axis);
|
||||
const label = fmt(v);
|
||||
const ly = ext.type === 'max' ? y - 8 : y + 15;
|
||||
ctx.strokeText(label, x, ly);
|
||||
ctx.fillText(label, x, ly);
|
||||
const ly = ext.type === 'max' ? y - off : y + off + 8;
|
||||
// keep the centred label fully inside the plot so it never clips
|
||||
const halfW = ctx.measureText(label).width / 2 + 2;
|
||||
const lx = Math.max(padLeft + halfW, Math.min(plotRight - halfW, x));
|
||||
ctx.strokeText(label, lx, ly);
|
||||
ctx.fillText(label, lx, ly);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -890,10 +951,12 @@
|
||||
// Touch gesture intent. On touch we defer pointer capture until we know
|
||||
// the finger is moving horizontally; a vertical drag is left to the page
|
||||
// so the meteograms don't hijack scrolling (and don't flash the tooltip).
|
||||
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' = 'none';
|
||||
let gesture: 'none' | 'scroll' | 'inspect' | 'pan' | 'pinch' | 'select' = 'none';
|
||||
let touchStart: { x: number; y: number; start: number; end: number } | null = null;
|
||||
let selStartX = 0; // drag-to-zoom anchor (canvas-local px)
|
||||
|
||||
const localX = (e: { clientX: number }): number => e.clientX - el.getBoundingClientRect().left;
|
||||
const clampPlotX = (x: number): number => Math.max(padLeft, Math.min(width - padRight, x));
|
||||
|
||||
const updateHover = (e: PointerEvent): void => {
|
||||
const t = pixToTime(localX(e));
|
||||
@@ -914,10 +977,13 @@
|
||||
}
|
||||
|
||||
if (e.pointerType === 'mouse') {
|
||||
// Mouse: click-drag selects a range to zoom into.
|
||||
el.setPointerCapture(e.pointerId);
|
||||
panStart = { x: e.clientX, start: viewStart, end: viewEnd };
|
||||
selStartX = clampPlotX(localX(e));
|
||||
dragSelect = null;
|
||||
panStart = null;
|
||||
pinchStart = null;
|
||||
gesture = zoomed ? 'pan' : 'inspect';
|
||||
gesture = 'select';
|
||||
} else {
|
||||
// Touch: wait for the first move to reveal scroll vs inspect intent.
|
||||
touchStart = { x: e.clientX, y: e.clientY, start: viewStart, end: viewEnd };
|
||||
@@ -941,6 +1007,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Mouse drag-to-zoom: track the selection rectangle
|
||||
if (gesture === 'select' && e.pointerType === 'mouse' && pointers.size === 1) {
|
||||
const x = clampPlotX(localX(e));
|
||||
if (dragSelect || Math.abs(x - selStartX) >= 3) {
|
||||
dragSelect = { x0: selStartX, x1: x };
|
||||
setHover(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve touch intent from the initial drag direction
|
||||
if (e.pointerType !== 'mouse' && gesture === 'none' && touchStart && pointers.size === 1) {
|
||||
const dx = Math.abs(e.clientX - touchStart.x);
|
||||
@@ -970,6 +1046,14 @@
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent): void => {
|
||||
// Commit a mouse drag-to-zoom selection (if it spans a real range).
|
||||
if (gesture === 'select' && e.pointerType === 'mouse' && dragSelect) {
|
||||
const a = pixToTime(dragSelect.x0);
|
||||
const b = pixToTime(dragSelect.x1);
|
||||
if (Math.abs(a - b) > 0) applyRange(Math.min(a, b), Math.max(a, b));
|
||||
}
|
||||
dragSelect = null;
|
||||
|
||||
pointers.delete(e.pointerId);
|
||||
if (pointers.size < 2) pinchStart = null;
|
||||
if (pointers.size < 1) {
|
||||
@@ -988,7 +1072,6 @@
|
||||
// Zoom only while Ctrl (or ⌘) is held — a plain scroll should keep
|
||||
// scrolling the page. Trackpad pinch also arrives as ctrlKey wheel.
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
showZoomHint();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
@@ -1034,16 +1117,32 @@
|
||||
style:touch-action="pan-y"
|
||||
></canvas>
|
||||
|
||||
<!-- Weather pictograms across the top of the plot -->
|
||||
<!-- Drag-to-zoom selection rectangle -->
|
||||
{#if dragSelect}
|
||||
<div
|
||||
class="pointer-events-none absolute z-20 border-x-2 border-primary/70 bg-primary/15"
|
||||
style:left="{Math.min(dragSelect.x0, dragSelect.x1)}px"
|
||||
style:top="{padTop}px"
|
||||
style:width="{Math.abs(dragSelect.x1 - dragSelect.x0)}px"
|
||||
style:height="{plotH}px"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Weather pictograms: a bordered band across the top of the plot -->
|
||||
{#if visiblePictograms.length > 0}
|
||||
<div class="pointer-events-none absolute inset-0 z-10" style:top="{padTop - 24}px">
|
||||
<div
|
||||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
||||
style:left="{iconBandLeft}px"
|
||||
style:top="{pictoRowTop}px"
|
||||
style:width="{iconBandWidth}px"
|
||||
style:height="{ICON_BAND_H}px"
|
||||
>
|
||||
{#each visiblePictograms as p (p.x)}
|
||||
<svg
|
||||
class="absolute fill-foreground"
|
||||
width="20"
|
||||
height="20"
|
||||
style:left="{p.x - 10}px"
|
||||
style:top="0"
|
||||
class="absolute top-px fill-foreground"
|
||||
width={ICON_PX}
|
||||
height={ICON_PX}
|
||||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, p.x - ICON_PX / 2))}px"
|
||||
>
|
||||
<use xlink:href="/images/weather-icons/{p.icon}.svg#Layer_1"></use>
|
||||
</svg>
|
||||
@@ -1051,16 +1150,28 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if zoomHintVisible}
|
||||
<!-- Wind-direction arrows: a matching band -->
|
||||
{#if visibleWindArrows.length > 0}
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 z-30 flex items-center justify-center rounded-md bg-black/25 transition-opacity"
|
||||
class="pointer-events-none absolute z-10 overflow-hidden rounded-lg border border-border/60 bg-muted/30"
|
||||
style:left="{iconBandLeft}px"
|
||||
style:top="{windRowTop}px"
|
||||
style:width="{iconBandWidth}px"
|
||||
style:height="{ICON_BAND_H}px"
|
||||
>
|
||||
<span
|
||||
class="rounded-lg bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
Hold <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 text-xs">Ctrl</kbd>
|
||||
and scroll to zoom
|
||||
</span>
|
||||
{#each visibleWindArrows as a (a.x)}
|
||||
<span
|
||||
class="absolute top-px inline-flex items-center justify-center"
|
||||
style:width="{ICON_PX}px"
|
||||
style:height="{ICON_PX}px"
|
||||
style:left="{Math.max(2, Math.min(iconBandWidth - ICON_PX - 2, a.x - ICON_PX / 2))}px"
|
||||
style:transform="rotate({a.deg}deg)"
|
||||
>
|
||||
<svg class="fill-foreground/80" width="22" height="22">
|
||||
<use xlink:href="/images/weather-icons/wi-direction-down.svg#Layer_1"></use>
|
||||
</svg>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* import { CanvasChart, buildDaylightBands, SERIES_COLORS } from '$lib/charts';
|
||||
*/
|
||||
|
||||
export { default as CanvasChart } from './CanvasChart.svelte';
|
||||
export { default as CanvasChart, setGroupHover, groupRange } from './CanvasChart.svelte';
|
||||
export type { ChartSeries } from './CanvasChart.svelte';
|
||||
|
||||
export { buildDaylightBands } from './bands';
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
extraPadding?: number;
|
||||
/** Minimum chart width in pixels; narrower viewports scroll sideways (default: 560) */
|
||||
minWidth?: number;
|
||||
/** Bleed the chart into the page gutters (edge-to-edge). Off when nested in a card. */
|
||||
bleed?: boolean;
|
||||
/** Optional CSS class for the outer wrapper */
|
||||
class?: string;
|
||||
/** Slot content (charts go here) */
|
||||
@@ -45,6 +47,7 @@
|
||||
chartHeight = 300,
|
||||
extraPadding = 2,
|
||||
minWidth = 560,
|
||||
bleed = true,
|
||||
class: className = '',
|
||||
children
|
||||
}: Props = $props();
|
||||
@@ -54,11 +57,11 @@
|
||||
let minHeight = $derived(chartHeight * chartCount + extraPadding);
|
||||
</script>
|
||||
|
||||
<div class="chart-bleed">
|
||||
<div class="chart-bleed" class:no-bleed={!bleed}>
|
||||
<div
|
||||
class="chart-container relative {className}"
|
||||
style:min-height="{minHeight}px"
|
||||
style:min-width="{minWidth}px"
|
||||
style="--chart-min-width: {minWidth}px"
|
||||
>
|
||||
<!-- Chart content area -->
|
||||
<div class="chart-content" in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
|
||||
@@ -100,18 +103,41 @@
|
||||
.chart-bleed {
|
||||
/* Bleed exactly into the page padding on mobile (main has p-5 =
|
||||
1.25rem) for edge-to-edge charts, and a bit past the content
|
||||
column on md+ (main has 2rem padding) for extra readability.
|
||||
Charts narrower than their min-width scroll sideways. */
|
||||
column on md+ (main has 2rem padding) for extra readability. */
|
||||
margin-left: -1.25rem;
|
||||
margin-right: -1.25rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chart-bleed.no-bleed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
min-width: var(--chart-min-width);
|
||||
}
|
||||
|
||||
/* Mobile: fit the chart to the viewport instead of forcing a min-width
|
||||
sideways scroll (which fights touch inspection). Pinch to zoom for detail. */
|
||||
@media (max-width: 767px) {
|
||||
.chart-container {
|
||||
min-width: 0;
|
||||
}
|
||||
.chart-bleed {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.chart-bleed {
|
||||
margin-left: -1.5rem;
|
||||
margin-right: -1.5rem;
|
||||
}
|
||||
.chart-bleed.no-bleed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-content {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy, tick } from 'svelte';
|
||||
|
||||
import { type GeoLocation } from '$lib/stores/settings';
|
||||
import {
|
||||
type GeoLocation,
|
||||
locationKey,
|
||||
storedFavoriteLocations,
|
||||
storedRecentLocations
|
||||
} from '$lib/stores/settings';
|
||||
|
||||
import * as Alert from '$lib/components/ui/alert';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -30,11 +35,31 @@
|
||||
};
|
||||
|
||||
const selectLocation = (location: GeoLocation) => {
|
||||
addRecent(location);
|
||||
searchQuery = '';
|
||||
closePopover();
|
||||
dispatch('location', location);
|
||||
};
|
||||
|
||||
function addRecent(loc: GeoLocation) {
|
||||
const key = locationKey(loc);
|
||||
storedRecentLocations.update((list) =>
|
||||
[loc, ...list.filter((l) => locationKey(l) !== key)].slice(0, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function toggleFavorite(loc: GeoLocation) {
|
||||
const key = locationKey(loc);
|
||||
storedFavoriteLocations.update((list) =>
|
||||
list.some((l) => locationKey(l) === key)
|
||||
? list.filter((l) => locationKey(l) !== key)
|
||||
: [loc, ...list].slice(0, 24)
|
||||
);
|
||||
}
|
||||
|
||||
$: favKeys = new Set($storedFavoriteLocations.map(locationKey));
|
||||
$: recentToShow = $storedRecentLocations.filter((l) => !favKeys.has(locationKey(l)));
|
||||
|
||||
async function focusInput() {
|
||||
await tick();
|
||||
searchInputEl?.focus();
|
||||
@@ -101,6 +126,55 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{#snippet locationRow(location: GeoLocation)}
|
||||
{@const fav = favKeys.has(locationKey(location))}
|
||||
<div
|
||||
class="group flex items-center rounded-md border border-transparent transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||
>
|
||||
<button
|
||||
class="flex min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-left"
|
||||
onclick={() => selectLocation(location)}
|
||||
>
|
||||
<img
|
||||
class="h-7 w-7 shrink-0 rounded-full"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-foreground">{location.name}</div>
|
||||
<div class="truncate text-xs text-muted-foreground">
|
||||
{location.admin1 || ''}
|
||||
{location.country || ''}
|
||||
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
||||
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
class="mr-1 flex h-7 w-7 shrink-0 cursor-pointer items-center justify-center rounded-md hover:bg-background hover:text-amber-500 {fav
|
||||
? 'text-amber-500'
|
||||
: 'text-muted-foreground/50'}"
|
||||
onclick={() => toggleFavorite(location)}
|
||||
aria-label={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={fav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill={fav ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3.6l2.5 5.1 5.6.8-4 3.9 1 5.6-5.1-2.7-5 2.7 1-5.6-4-3.9 5.5-.8z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Popover.Root bind:open={popoverOpen}>
|
||||
<Popover.Trigger
|
||||
class="flex h-10 w-full cursor-pointer items-center gap-2.5 rounded-full border-2 border-primary/30 bg-background px-4 text-[0.8125rem] font-medium text-muted-foreground shadow-xs transition-[border-color,box-shadow] duration-150 hover:border-primary/70 hover:shadow-md"
|
||||
@@ -178,8 +252,35 @@
|
||||
</div>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if results.results && results.results.length === 0}
|
||||
{#if searchQuery.length < 2}
|
||||
{#if searchQuery.length < 2}
|
||||
{#if $storedFavoriteLocations.length > 0 || recentToShow.length > 0}
|
||||
{#if $storedFavoriteLocations.length > 0}
|
||||
<div
|
||||
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
Favorites
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each $storedFavoriteLocations as loc (locationKey(loc))}
|
||||
{@render locationRow(loc)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if recentToShow.length > 0}
|
||||
<div
|
||||
class="mb-1 px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase {$storedFavoriteLocations.length
|
||||
? 'mt-3'
|
||||
: ''}"
|
||||
>
|
||||
Recent
|
||||
</div>
|
||||
<div class="space-y-0.5">
|
||||
{#each recentToShow as loc (locationKey(loc))}
|
||||
{@render locationRow(loc)}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div
|
||||
class="flex items-start gap-2 rounded-md bg-primary/8 p-2.5 text-muted-foreground"
|
||||
>
|
||||
@@ -200,62 +301,25 @@
|
||||
Start typing to search or use GPS to detect your position
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<Alert.Root
|
||||
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
||||
>
|
||||
<Alert.Description class="text-orange-700 dark:text-orange-300">
|
||||
No locations found for "{searchQuery}". Try a different term.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
{:else if !results.results}
|
||||
{:else if results.results && results.results.length > 0}
|
||||
<div class="space-y-0.5">
|
||||
{#each results.results as location, i (i)}
|
||||
{@render locationRow(location)}
|
||||
{/each}
|
||||
</div>
|
||||
{:else if results.results}
|
||||
<Alert.Root
|
||||
class="border-orange-200 bg-orange-50 dark:border-orange-800 dark:bg-orange-900/20"
|
||||
>
|
||||
<Alert.Description class="text-orange-700 dark:text-orange-300">
|
||||
No locations found for "{searchQuery}". Try a different term.
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{:else}
|
||||
<Alert.Root variant="destructive">
|
||||
<Alert.Description>No locations found</Alert.Description>
|
||||
</Alert.Root>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each results.results || [] as location, i (i)}
|
||||
<button
|
||||
class="group block w-full cursor-pointer rounded-md border border-transparent bg-transparent px-2.5 py-2 transition-[background,border-color] duration-150 hover:border-border hover:bg-accent"
|
||||
onclick={() => selectLocation(location)}
|
||||
>
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img
|
||||
class="h-7 w-7 rounded-full"
|
||||
src="/images/country-flags/{(
|
||||
location.country_code || 'united_nations'
|
||||
).toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
/>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="text-sm font-medium text-foreground">
|
||||
{location.name}
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{location.admin1 || ''}
|
||||
{location.country || ''}
|
||||
· {location.latitude.toFixed(2)}°N {location.longitude.toFixed(2)}°E
|
||||
{#if location.elevation}· {location.elevation.toFixed(0)}m{/if}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-muted-foreground opacity-0 transition-opacity duration-150 group-hover:opacity-100"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert.Root variant="destructive">
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { buildLocationRoute } from '$lib/utils/location';
|
||||
|
||||
import LocationSearch from '$lib/components/location/location-search.svelte';
|
||||
import UnitSelector from '$lib/components/unit-selector.svelte';
|
||||
|
||||
interface Props {
|
||||
onMenuToggle?: () => void;
|
||||
@@ -68,22 +69,23 @@
|
||||
<!-- Current location display -->
|
||||
{#if location}
|
||||
<div
|
||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 sm:flex"
|
||||
class="hidden items-center gap-2 rounded-full border border-border/70 bg-muted/40 py-1 ps-1 pe-3 lg:flex"
|
||||
>
|
||||
<img
|
||||
class="h-6 w-6 rounded-full ring-1 ring-border"
|
||||
class="h-6 w-6 shrink-0 rounded-full ring-1 ring-border"
|
||||
src="/images/country-flags/{(location.country_code || 'united_nations').toLowerCase()}.svg"
|
||||
alt={location.country}
|
||||
/>
|
||||
<span class="text-sm font-semibold text-foreground">
|
||||
<!-- full location (desktop); the page hero carries it on smaller screens -->
|
||||
<span class="whitespace-nowrap text-sm font-semibold text-foreground">
|
||||
{location.name}
|
||||
{#if location.admin1 || location.country}
|
||||
<span class="font-normal text-muted-foreground">
|
||||
· {#if location.admin1}{location.admin1},
|
||||
{/if}{location.country ?? ''}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if location.admin1 || location.country}
|
||||
<span class="hidden text-xs text-muted-foreground lg:inline">
|
||||
{#if location.admin1}{location.admin1},{/if}
|
||||
{location.country}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -100,6 +102,9 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Measurement units -->
|
||||
<UnitSelector />
|
||||
|
||||
<!-- Theme toggle: system → light → dark -->
|
||||
<button
|
||||
class="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border/70 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
|
||||
@@ -125,30 +125,36 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Collapse toggle -->
|
||||
<div class="border-t border-sidebar-border px-2 py-3">
|
||||
<button
|
||||
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||
onclick={onToggle}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<svg
|
||||
class="h-4.5 w-4.5 transition-transform duration-200"
|
||||
class:rotate-180={collapsed}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Collapse toggle (desktop sidebar only; the mobile drawer omits onToggle) -->
|
||||
{#if onToggle}
|
||||
<div class="border-t border-sidebar-border px-2 py-3">
|
||||
<button
|
||||
class="relative flex w-full items-center rounded-md px-2.5 py-2 text-sm font-medium text-sidebar-foreground opacity-70 transition-colors duration-150 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:opacity-100"
|
||||
onclick={onToggle}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<div class="flex h-5 w-5 shrink-0 items-center justify-center">
|
||||
<svg
|
||||
class="h-4.5 w-4.5 transition-transform duration-200"
|
||||
class:rotate-180={collapsed}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{#if !collapsed}
|
||||
<span class="ml-2.5 whitespace-nowrap">Collapse</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { type UnitPrefs, storedUnits } from '$lib/stores/settings';
|
||||
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
|
||||
// each group maps a stored unit key to its selectable options
|
||||
const UNIT_GROUPS: {
|
||||
key: keyof UnitPrefs;
|
||||
label: string;
|
||||
options: { value: string; label: string }[];
|
||||
}[] = [
|
||||
{
|
||||
key: 'temperature_unit',
|
||||
label: 'Temperature',
|
||||
options: [
|
||||
{ value: 'celsius', label: '°C' },
|
||||
{ value: 'fahrenheit', label: '°F' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'wind_speed_unit',
|
||||
label: 'Wind speed',
|
||||
options: [
|
||||
{ value: 'kmh', label: 'km/h' },
|
||||
{ value: 'ms', label: 'm/s' },
|
||||
{ value: 'mph', label: 'mph' },
|
||||
{ value: 'kn', label: 'kn' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'precipitation_unit',
|
||||
label: 'Precipitation',
|
||||
options: [
|
||||
{ value: 'mm', label: 'mm' },
|
||||
{ value: 'inch', label: 'inch' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function setUnit(key: keyof UnitPrefs, value: string) {
|
||||
storedUnits.update((u) => ({ ...u, [key]: value }));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class="flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/70 px-2.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[state=open]:bg-muted data-[state=open]:text-foreground"
|
||||
aria-label="Choose measurement units"
|
||||
title="Units"
|
||||
>
|
||||
<!-- gauge icon -->
|
||||
<svg
|
||||
class="h-4.5 w-4.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.5 15a7.5 7.5 0 1 1 15 0M12 15l3.2-3.2"
|
||||
/>
|
||||
<circle cx="12" cy="15" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
<span class="hidden text-xs font-semibold sm:inline">Units</span>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="end" class="w-64 border-border">
|
||||
<div class="flex flex-col gap-4">
|
||||
{#each UNIT_GROUPS as group (group.key)}
|
||||
<div>
|
||||
<span
|
||||
class="mb-1.5 block text-[11px] font-semibold tracking-wide text-muted-foreground uppercase"
|
||||
>
|
||||
{group.label}
|
||||
</span>
|
||||
<div class="flex gap-1 rounded-lg bg-muted p-0.5">
|
||||
{#each group.options as opt (opt.value)}
|
||||
{@const active = $storedUnits[group.key] === opt.value}
|
||||
<button
|
||||
class="flex-1 cursor-pointer rounded-md px-2 py-1.5 text-[13px] font-semibold transition-colors {active
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'}"
|
||||
aria-pressed={active}
|
||||
onclick={() => setUnit(group.key, opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -90,7 +90,7 @@ export interface ChartPanel {
|
||||
}
|
||||
|
||||
export const defaultChartLayout: ChartPanel[] = [
|
||||
{ id: 'panel-1', variables: ['temperature', 'cloud_cover'] },
|
||||
{ id: 'panel-1', variables: ['weather_icons', 'temperature', 'cloud_cover'] },
|
||||
{ id: 'panel-2', variables: ['precipitation', 'precipitation_probability'] },
|
||||
{ id: 'panel-3', variables: ['wind', 'humidity'] }
|
||||
];
|
||||
@@ -99,3 +99,27 @@ export const storedChartLayout = persisted<ChartPanel[]>('chart_layout_v1', defa
|
||||
|
||||
/** Selected ensemble model for the 14-day spread forecast. */
|
||||
export const storedEnsembleModel = persisted<string>('ensemble_model', 'ncep_gefs_seamless');
|
||||
|
||||
/** Measurement units, shared across every forecast page and persisted. */
|
||||
export interface UnitPrefs {
|
||||
temperature_unit: 'celsius' | 'fahrenheit';
|
||||
wind_speed_unit: 'kmh' | 'ms' | 'mph' | 'kn';
|
||||
precipitation_unit: 'mm' | 'inch';
|
||||
}
|
||||
|
||||
export const defaultUnits: UnitPrefs = {
|
||||
temperature_unit: 'celsius',
|
||||
wind_speed_unit: 'kmh',
|
||||
precipitation_unit: 'mm'
|
||||
};
|
||||
|
||||
export const storedUnits = persisted<UnitPrefs>('units_v1', defaultUnits);
|
||||
|
||||
/** Recently visited and starred locations, shown in the search dropdown. */
|
||||
export const storedRecentLocations = persisted<GeoLocation[]>('recent_locations_v1', []);
|
||||
export const storedFavoriteLocations = persisted<GeoLocation[]>('favorite_locations_v1', []);
|
||||
|
||||
/** Stable key for de-duping locations (geocoding id, or rounded coordinates). */
|
||||
export function locationKey(l: GeoLocation): string {
|
||||
return l.id && l.id !== 0 ? `id:${l.id}` : `c:${l.latitude.toFixed(3)},${l.longitude.toFixed(3)}`;
|
||||
}
|
||||
|
||||
@@ -115,7 +115,10 @@ export async function resolveLocationFromRoute({
|
||||
location = candidate;
|
||||
}
|
||||
|
||||
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}`;
|
||||
// trailingSlash is 'always' (see routes/+layout.ts), so the router serves
|
||||
// every path with a trailing slash. Match that here or the equality check
|
||||
// never holds and the redirect loops forever.
|
||||
const canonicalPath = `${routePrefix}${buildLocationRoute(location)}/`;
|
||||
if (event.url.pathname !== canonicalPath) {
|
||||
throw redirect(303, canonicalPath);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user