theme toggle with dark map style and mapterhorn terrain

This commit is contained in:
Vincent van der Wal
2026-07-22 11:26:26 +02:00
parent addef8b78e
commit bca5154459
5 changed files with 199 additions and 42 deletions
+21 -1
View File
@@ -18,8 +18,10 @@
--critical: #d03b3b; --critical: #d03b3b;
} }
/* dark tokens apply when the OS prefers dark (unless the user forced light),
or when the user forced dark via the theme toggle */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root:where(:not([data-theme='light'])) {
color-scheme: dark; color-scheme: dark;
--page: #0d0d0d; --page: #0d0d0d;
--surface-1: #1a1a19; --surface-1: #1a1a19;
@@ -38,6 +40,24 @@
--good-text: #0ca30c; --good-text: #0ca30c;
} }
} }
:root[data-theme='dark'] {
color-scheme: dark;
--page: #0d0d0d;
--surface-1: #1a1a19;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--grid: #2c2c2a;
--baseline: #383835;
--border: rgba(255, 255, 255, 0.1);
--wash: rgba(255, 255, 255, 0.06);
--accent: #3987e5;
--accent-strong: #6da7ec;
--accent-wash: rgba(57, 135, 229, 0.16);
--accent-track: #184f95;
--good: #0ca30c;
--good-text: #0ca30c;
}
* { * {
box-sizing: border-box; box-sizing: border-box;
+5
View File
@@ -4,6 +4,11 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
// apply the stored theme before first paint to avoid a flash
const t = localStorage.getItem('theme');
if (t === 'light' || t === 'dark') document.documentElement.dataset.theme = t;
</script>
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+72 -23
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { theme } from '$lib/theme.svelte';
export interface MapTrack { export interface MapTrack {
id?: number; id?: number;
@@ -15,10 +16,6 @@
lon: number; lon: number;
climbed: boolean; climbed: boolean;
} }
const STYLE_URL = 'https://maptiler.servert.nl/styles/minimal-world-maps/style.json';
const TRACK_COLOR = '#2a78d6';
export interface Viewport { export interface Viewport {
minLat: number; minLat: number;
minLon: number; minLon: number;
@@ -27,6 +24,10 @@
zoom: number; zoom: number;
} }
const styleUrl = (dark: boolean) =>
`https://maptiler.servert.nl/styles/minimal-world-maps${dark ? '-dark' : ''}/style.json`;
const TERRAIN_TILEJSON = 'https://tiles.mapterhorn.com/tilejson.json';
let { let {
tracks = [], tracks = [],
peaks = [], peaks = [],
@@ -45,6 +46,8 @@
let map: import('maplibre-gl').Map | undefined; let map: import('maplibre-gl').Map | undefined;
let lib: typeof import('maplibre-gl') | undefined; let lib: typeof import('maplibre-gl') | undefined;
let markers: import('maplibre-gl').Marker[] = []; let markers: import('maplibre-gl').Marker[] = [];
let currentDark: boolean | undefined;
let pendingTerrain: unknown = null;
function renderPeaks() { function renderPeaks() {
if (!lib || !map) return; if (!lib || !map) return;
@@ -68,24 +71,40 @@
renderPeaks(); renderPeaks();
}); });
onMount(() => { // swap map style when the theme changes
let cancelled = false; $effect(() => {
(async () => { const dark = theme.isDark;
const maplibre = await import('maplibre-gl'); if (map && currentDark !== undefined && currentDark !== dark) {
if (cancelled) return; currentDark = dark;
lib = maplibre; pendingTerrain = map.getTerrain();
map.setStyle(styleUrl(dark));
map = new maplibre.Map({ }
container,
style: STYLE_URL,
center: [9.5, 46.5], // the Alps
zoom: 5,
attributionControl: { compact: true }
}); });
map.addControl(new maplibre.NavigationControl({ showCompass: false }), 'top-right');
map.on('load', () => { /** Sources and layers vanish on setStyle, so (re-)add them on every style load. */
function addLayers(dark: boolean) {
if (!map) return; if (!map) return;
// Mapterhorn DEM: one source for 3D terrain, one for hillshade
// (sharing a raster-dem source between both is discouraged by MapLibre)
for (const id of ['terrain-dem', 'hillshade-dem']) {
map.addSource(id, { type: 'raster-dem', url: TERRAIN_TILEJSON });
}
const firstSymbol = map.getStyle().layers.find((l) => l.type === 'symbol')?.id;
map.addLayer(
{
id: 'hillshade',
type: 'hillshade',
source: 'hillshade-dem',
paint: {
'hillshade-exaggeration': 0.3,
'hillshade-shadow-color': dark ? '#000000' : '#5a5a50',
'hillshade-highlight-color': dark ? '#3a3a38' : '#ffffff'
}
},
firstSymbol
);
map.addSource('tracks', { map.addSource('tracks', {
type: 'geojson', type: 'geojson',
data: { data: {
@@ -105,18 +124,46 @@
type: 'line', type: 'line',
source: 'tracks', source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': '#ffffff', 'line-width': 5, 'line-opacity': 0.6 } paint: { 'line-color': dark ? '#1a1a19' : '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
}); });
map.addLayer({ map.addLayer({
id: 'tracks-line', id: 'tracks-line',
type: 'line', type: 'line',
source: 'tracks', source: 'tracks',
layout: { 'line-join': 'round', 'line-cap': 'round' }, layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': TRACK_COLOR, 'line-width': 2.5 } paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 }
});
}); });
renderPeaks(); // restore 3D terrain across style swaps
if (pendingTerrain) {
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
pendingTerrain = null;
}
}
onMount(() => {
let cancelled = false;
(async () => {
const maplibre = await import('maplibre-gl');
if (cancelled) return;
lib = maplibre;
currentDark = theme.isDark;
map = new maplibre.Map({
container,
style: styleUrl(currentDark),
center: [9.5, 46.5], // the Alps
zoom: 5,
maxPitch: 70,
attributionControl: { compact: true }
});
map.addControl(new maplibre.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(
new maplibre.TerrainControl({ source: 'terrain-dem', exaggeration: 1.2 }),
'top-right'
);
map.on('style.load', () => addLayers(currentDark ?? false));
if (onviewport) { if (onviewport) {
const report = () => { const report = () => {
@@ -134,6 +181,8 @@
map.once('load', report); map.once('load', report);
} }
renderPeaks();
const bounds = new maplibre.LngLatBounds(); const bounds = new maplibre.LngLatBounds();
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]); for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
for (const p of peaks) bounds.extend([p.lon, p.lat]); for (const p of peaks) bounds.extend([p.lon, p.lat]);
+37
View File
@@ -0,0 +1,37 @@
import { browser } from '$app/environment';
export type ThemePref = 'auto' | 'light' | 'dark';
let pref = $state<ThemePref>('auto');
let systemDark = $state(false);
if (browser) {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') pref = stored;
const mq = window.matchMedia('(prefers-color-scheme: dark)');
systemDark = mq.matches;
mq.addEventListener('change', (e) => (systemDark = e.matches));
}
function apply() {
if (!browser) return;
if (pref === 'auto') delete document.documentElement.dataset.theme;
else document.documentElement.dataset.theme = pref;
}
apply();
export const theme = {
get pref(): ThemePref {
return pref;
},
/** the resolved mode, following the system when pref is "auto" */
get isDark(): boolean {
return pref === 'dark' || (pref === 'auto' && systemDark);
},
cycle(): void {
pref = pref === 'auto' ? 'light' : pref === 'light' ? 'dark' : 'auto';
if (pref === 'auto') localStorage.removeItem('theme');
else localStorage.setItem('theme', pref);
apply();
}
};
+48 -2
View File
@@ -2,6 +2,15 @@
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import '../app.css'; import '../app.css';
import { page } from '$app/state'; import { page } from '$app/state';
import { theme } from '$lib/theme.svelte';
const themeTitle = $derived(
theme.pref === 'auto'
? 'Theme: follows your system - click for light'
: theme.pref === 'light'
? 'Theme: light - click for dark'
: 'Theme: dark - click to follow your system'
);
let { children, data } = $props(); let { children, data } = $props();
@@ -46,6 +55,25 @@
<a href="/signup" class="btn signup-btn">Sign up</a> <a href="/signup" class="btn signup-btn">Sign up</a>
</div> </div>
{/if} {/if}
<button class="theme-toggle" onclick={() => theme.cycle()} title={themeTitle} aria-label={themeTitle}>
{#if theme.pref === 'auto'}
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="2" />
<path d="M12 3 a9 9 0 0 1 0 18 Z" fill="currentColor" />
</svg>
{:else if theme.pref === 'light'}
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<circle cx="12" cy="12" r="4.5" fill="currentColor" />
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 2.5v3M12 18.5v3M2.5 12h3M18.5 12h3M5.3 5.3l2.1 2.1M16.6 16.6l2.1 2.1M18.7 5.3l-2.1 2.1M7.4 16.6l-2.1 2.1" />
</g>
</svg>
{:else}
<svg viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5Z" fill="currentColor" />
</svg>
{/if}
</button>
</nav> </nav>
</header> </header>
@@ -71,10 +99,10 @@
height: 3.5rem; height: 3.5rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; gap: 0.75rem;
gap: 1rem;
} }
.wordmark { .wordmark {
margin-right: auto;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -112,6 +140,24 @@
padding: 0.35rem 0.85rem; padding: 0.35rem 0.85rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background 120ms, color 120ms;
}
.theme-toggle:hover {
background: var(--wash);
color: var(--text-primary);
}
.user { .user {
display: flex; display: flex;
align-items: center; align-items: center;