theme toggle with dark map style and mapterhorn terrain
This commit is contained in:
+21
-1
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,6 +71,76 @@
|
|||||||
renderPeaks();
|
renderPeaks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// swap map style when the theme changes
|
||||||
|
$effect(() => {
|
||||||
|
const dark = theme.isDark;
|
||||||
|
if (map && currentDark !== undefined && currentDark !== dark) {
|
||||||
|
currentDark = dark;
|
||||||
|
pendingTerrain = map.getTerrain();
|
||||||
|
map.setStyle(styleUrl(dark));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sources and layers vanish on setStyle, so (re-)add them on every style load. */
|
||||||
|
function addLayers(dark: boolean) {
|
||||||
|
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', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: tracks.map((track) => ({
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { name: track.name ?? '' },
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'tracks-casing',
|
||||||
|
type: 'line',
|
||||||
|
source: 'tracks',
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': dark ? '#1a1a19' : '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'tracks-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'tracks',
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 }
|
||||||
|
});
|
||||||
|
|
||||||
|
// restore 3D terrain across style swaps
|
||||||
|
if (pendingTerrain) {
|
||||||
|
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
||||||
|
pendingTerrain = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -75,48 +148,22 @@
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
lib = maplibre;
|
lib = maplibre;
|
||||||
|
|
||||||
|
currentDark = theme.isDark;
|
||||||
map = new maplibre.Map({
|
map = new maplibre.Map({
|
||||||
container,
|
container,
|
||||||
style: STYLE_URL,
|
style: styleUrl(currentDark),
|
||||||
center: [9.5, 46.5], // the Alps
|
center: [9.5, 46.5], // the Alps
|
||||||
zoom: 5,
|
zoom: 5,
|
||||||
|
maxPitch: 70,
|
||||||
attributionControl: { compact: true }
|
attributionControl: { compact: true }
|
||||||
});
|
});
|
||||||
map.addControl(new maplibre.NavigationControl({ showCompass: false }), 'top-right');
|
map.addControl(new maplibre.NavigationControl({ visualizePitch: true }), 'top-right');
|
||||||
|
map.addControl(
|
||||||
|
new maplibre.TerrainControl({ source: 'terrain-dem', exaggeration: 1.2 }),
|
||||||
|
'top-right'
|
||||||
|
);
|
||||||
|
|
||||||
map.on('load', () => {
|
map.on('style.load', () => addLayers(currentDark ?? false));
|
||||||
if (!map) return;
|
|
||||||
map.addSource('tracks', {
|
|
||||||
type: 'geojson',
|
|
||||||
data: {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: tracks.map((track) => ({
|
|
||||||
type: 'Feature',
|
|
||||||
properties: { name: track.name ?? '' },
|
|
||||||
geometry: {
|
|
||||||
type: 'LineString',
|
|
||||||
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'tracks-casing',
|
|
||||||
type: 'line',
|
|
||||||
source: 'tracks',
|
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
||||||
paint: { 'line-color': '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
|
|
||||||
});
|
|
||||||
map.addLayer({
|
|
||||||
id: 'tracks-line',
|
|
||||||
type: 'line',
|
|
||||||
source: 'tracks',
|
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
|
||||||
paint: { 'line-color': TRACK_COLOR, 'line-width': 2.5 }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
renderPeaks();
|
|
||||||
|
|
||||||
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]);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user