|
|
|
@@ -0,0 +1,410 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { onMount, onDestroy } from 'svelte';
|
|
|
|
|
import maplibregl from 'maplibre-gl';
|
|
|
|
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
|
|
|
|
|
|
|
|
|
export let handle: string;
|
|
|
|
|
export let base: string;
|
|
|
|
|
export let embedded: boolean = false;
|
|
|
|
|
|
|
|
|
|
interface Track { id: string; date: string; type: string; name: string; dist: number; coords: [number,number][]; }
|
|
|
|
|
|
|
|
|
|
let mapEl: HTMLDivElement;
|
|
|
|
|
let map: any;
|
|
|
|
|
let mapReady = false;
|
|
|
|
|
|
|
|
|
|
let tracks: Track[] = [];
|
|
|
|
|
let loading = true;
|
|
|
|
|
let error = '';
|
|
|
|
|
|
|
|
|
|
// Filters
|
|
|
|
|
let selectedTypes: Set<string> = new Set();
|
|
|
|
|
let dateFrom = '';
|
|
|
|
|
let dateTo = '';
|
|
|
|
|
let selectedYear: string | null = null;
|
|
|
|
|
|
|
|
|
|
// View
|
|
|
|
|
let viewMode: 'lines' | 'heatmap' = 'lines';
|
|
|
|
|
let heatmapMode: 'global' | 'bytype' = 'global';
|
|
|
|
|
|
|
|
|
|
// Tile layers — same as planner
|
|
|
|
|
const TILES: Record<string, { tiles: string[]; attribution: string; label: string }> = {
|
|
|
|
|
cyclosm: { label: 'Cycle', attribution: '© CyclOSM | © OpenStreetMap contributors', tiles: ['https://a.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png','https://b.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png','https://c.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png'] },
|
|
|
|
|
osm: { label: 'OSM', attribution: '© OpenStreetMap contributors', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'] },
|
|
|
|
|
topo: { label: 'Topo', attribution: '© OpenTopoMap | © OpenStreetMap contributors', tiles: ['https://a.tile.opentopomap.org/{z}/{x}/{y}.png','https://b.tile.opentopomap.org/{z}/{x}/{y}.png','https://c.tile.opentopomap.org/{z}/{x}/{y}.png'] },
|
|
|
|
|
satellite: { label: 'Sat', attribution: '© Esri World Imagery', tiles: ['https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'] },
|
|
|
|
|
};
|
|
|
|
|
const TILE_ORDER = ['cyclosm', 'osm', 'topo', 'satellite'];
|
|
|
|
|
let tileKey = 'cyclosm';
|
|
|
|
|
|
|
|
|
|
const TYPE_COLORS: Record<string, string> = {
|
|
|
|
|
cycling: '#e879a0',
|
|
|
|
|
running: '#60a5fa',
|
|
|
|
|
hiking: '#4ade80',
|
|
|
|
|
skiing: '#93c5fd',
|
|
|
|
|
other: '#a78bfa',
|
|
|
|
|
};
|
|
|
|
|
const HEAT_TYPES = Object.keys(TYPE_COLORS);
|
|
|
|
|
|
|
|
|
|
function typeColor(t: string): string { return TYPE_COLORS[t] ?? TYPE_COLORS.other; }
|
|
|
|
|
|
|
|
|
|
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
|
|
|
|
|
|
|
|
// ── Derived ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
$: allTypes = [...new Set(tracks.map(t => t.type))].sort();
|
|
|
|
|
$: availableYears = [...new Set(tracks.map(t => t.date.slice(0,4)).filter(Boolean))].sort().reverse();
|
|
|
|
|
|
|
|
|
|
$: if (allTypes.length > 0 && selectedTypes.size === 0) selectedTypes = new Set(allTypes);
|
|
|
|
|
|
|
|
|
|
$: filteredTracks = tracks.filter(t => {
|
|
|
|
|
if (!selectedTypes.has(t.type)) return false;
|
|
|
|
|
if (dateFrom && t.date < dateFrom) return false;
|
|
|
|
|
if (dateTo && t.date > dateTo) return false;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$: if (mapReady) _updateMap(filteredTracks, viewMode, heatmapMode);
|
|
|
|
|
|
|
|
|
|
// ── Filters ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function setTile(key: string) {
|
|
|
|
|
tileKey = key;
|
|
|
|
|
map?.getSource('base')?.setTiles(TILES[key].tiles);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleType(t: string) {
|
|
|
|
|
const s = new Set(selectedTypes);
|
|
|
|
|
s.has(t) ? s.delete(t) : s.add(t);
|
|
|
|
|
selectedTypes = s;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectAllTypes() { selectedTypes = new Set(allTypes); }
|
|
|
|
|
function clearAllTypes() { selectedTypes = new Set(); }
|
|
|
|
|
|
|
|
|
|
function setYear(y: string) {
|
|
|
|
|
if (selectedYear === y) { selectedYear = null; dateFrom = ''; dateTo = ''; return; }
|
|
|
|
|
selectedYear = y;
|
|
|
|
|
dateFrom = `${y}-01-01`;
|
|
|
|
|
dateTo = `${y}-12-31`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setMonth(m: number) { // m: 1-12
|
|
|
|
|
if (!selectedYear) return;
|
|
|
|
|
const mm = String(m).padStart(2, '0');
|
|
|
|
|
const last = new Date(+selectedYear, m, 0).getDate();
|
|
|
|
|
dateFrom = `${selectedYear}-${mm}-01`;
|
|
|
|
|
dateTo = `${selectedYear}-${mm}-${String(last).padStart(2,'0')}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function clearDates() { dateFrom = ''; dateTo = ''; selectedYear = null; }
|
|
|
|
|
|
|
|
|
|
// ── Map ────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function _linesGeoJSON(ts: Track[]) {
|
|
|
|
|
return { type: 'FeatureCollection', features: ts.map(t => ({
|
|
|
|
|
type: 'Feature',
|
|
|
|
|
geometry: { type: 'LineString', coordinates: t.coords },
|
|
|
|
|
properties: { type: t.type },
|
|
|
|
|
}))};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _heatGeoJSON(ts: Track[]) {
|
|
|
|
|
const features: any[] = [];
|
|
|
|
|
for (const t of ts)
|
|
|
|
|
for (const [lng, lat] of t.coords)
|
|
|
|
|
features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { type: t.type } });
|
|
|
|
|
return { type: 'FeatureCollection', features };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _empty() { return { type: 'FeatureCollection', features: [] }; }
|
|
|
|
|
|
|
|
|
|
function _hexRgb(hex: string): [number,number,number] {
|
|
|
|
|
const m = hex.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
|
|
|
|
|
return m ? [parseInt(m[1],16), parseInt(m[2],16), parseInt(m[3],16)] : [160,160,160];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _updateMap(filtered: Track[], view: string, heatMode: string) {
|
|
|
|
|
const linesSrc = map.getSource('explore-lines');
|
|
|
|
|
const heatSrc = map.getSource('explore-heat');
|
|
|
|
|
if (!linesSrc || !heatSrc) return;
|
|
|
|
|
|
|
|
|
|
linesSrc.setData(view === 'lines' ? _linesGeoJSON(filtered) : _empty());
|
|
|
|
|
heatSrc.setData(view === 'heatmap' ? _heatGeoJSON(filtered) : _empty());
|
|
|
|
|
|
|
|
|
|
map.setLayoutProperty('explore-lines', 'visibility', view === 'lines' ? 'visible' : 'none');
|
|
|
|
|
map.setLayoutProperty('explore-heat-global', 'visibility', view === 'heatmap' && heatMode === 'global' ? 'visible' : 'none');
|
|
|
|
|
for (const t of HEAT_TYPES)
|
|
|
|
|
map.setLayoutProperty(`explore-heat-${t}`, 'visibility', view === 'heatmap' && heatMode === 'bytype' ? 'visible' : 'none');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function _fitBounds(ts: Track[]) {
|
|
|
|
|
if (!ts.length) return;
|
|
|
|
|
let w = Infinity, e = -Infinity, s = Infinity, n = -Infinity;
|
|
|
|
|
for (const t of ts) for (const [lng, lat] of t.coords) {
|
|
|
|
|
if (lng < w) w = lng; if (lng > e) e = lng;
|
|
|
|
|
if (lat < s) s = lat; if (lat > n) n = lat;
|
|
|
|
|
}
|
|
|
|
|
if (w === Infinity) return;
|
|
|
|
|
map.fitBounds([[w,s],[e,n]], { padding: 40, maxZoom: 14 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onMount(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const r = await fetch('/api/me/tracks', { credentials: 'include' });
|
|
|
|
|
if (!r.ok) { error = r.status === 404 ? 'No tracks baked yet — upload activities first.' : `Error ${r.status}`; loading = false; return; }
|
|
|
|
|
const d = await r.json();
|
|
|
|
|
tracks = d.tracks ?? [];
|
|
|
|
|
} catch (e: any) { error = e.message ?? 'Failed to load'; loading = false; return; }
|
|
|
|
|
loading = false;
|
|
|
|
|
|
|
|
|
|
map = new maplibregl.Map({
|
|
|
|
|
container: mapEl,
|
|
|
|
|
style: { version: 8,
|
|
|
|
|
sources: { base: { type: 'raster', tiles: TILES.cyclosm.tiles, tileSize: 256, attribution: TILES.cyclosm.attribution } },
|
|
|
|
|
layers: [{ id: 'base', type: 'raster', source: 'base' }],
|
|
|
|
|
},
|
|
|
|
|
center: [12, 42], zoom: 5,
|
|
|
|
|
});
|
|
|
|
|
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
|
|
|
|
|
|
|
|
|
map.on('load', () => {
|
|
|
|
|
map.addSource('explore-lines', { type: 'geojson', data: _empty() });
|
|
|
|
|
map.addSource('explore-heat', { type: 'geojson', data: _empty() });
|
|
|
|
|
|
|
|
|
|
// Lines layer — color by type
|
|
|
|
|
map.addLayer({ id: 'explore-lines', type: 'line', source: 'explore-lines', layout: { visibility: 'none' },
|
|
|
|
|
paint: { 'line-width': 2, 'line-opacity': 0.5,
|
|
|
|
|
'line-color': ['match', ['get', 'type'],
|
|
|
|
|
'cycling', TYPE_COLORS.cycling, 'running', TYPE_COLORS.running,
|
|
|
|
|
'hiking', TYPE_COLORS.hiking, 'skiing', TYPE_COLORS.skiing,
|
|
|
|
|
TYPE_COLORS.other],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Global heatmap
|
|
|
|
|
map.addLayer({ id: 'explore-heat-global', type: 'heatmap', source: 'explore-heat', layout: { visibility: 'none' },
|
|
|
|
|
paint: { 'heatmap-radius': 14, 'heatmap-opacity': 0.85,
|
|
|
|
|
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
|
|
|
|
|
0, 'rgba(0,0,0,0)', 0.2, '#4ade80', 0.5, '#facc15', 0.8, '#f97316', 1, '#ef4444'],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Per-type heatmap layers
|
|
|
|
|
for (const [type, hex] of Object.entries(TYPE_COLORS)) {
|
|
|
|
|
const [r,g,b] = _hexRgb(hex);
|
|
|
|
|
map.addLayer({ id: `explore-heat-${type}`, type: 'heatmap', source: 'explore-heat',
|
|
|
|
|
filter: ['==', ['get', 'type'], type], layout: { visibility: 'none' },
|
|
|
|
|
paint: { 'heatmap-radius': 14, 'heatmap-opacity': 0.85,
|
|
|
|
|
'heatmap-color': ['interpolate', ['linear'], ['heatmap-density'],
|
|
|
|
|
0, `rgba(${r},${g},${b},0)`, 0.3, `rgba(${r},${g},${b},0.4)`, 1, `rgba(${r},${g},${b},1)`],
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mapReady = true;
|
|
|
|
|
_updateMap(tracks, viewMode, heatmapMode);
|
|
|
|
|
_fitBounds(tracks);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
onDestroy(() => { if (map) map.remove(); });
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="explore-layout">
|
|
|
|
|
<!-- Sidebar -->
|
|
|
|
|
<aside class="sidebar">
|
|
|
|
|
{#if !embedded}
|
|
|
|
|
<header class="sidebar-header">
|
|
|
|
|
<span class="sidebar-title">Explore</span>
|
|
|
|
|
<a href="{base}u/{handle}/athlete/" class="back-link">← Athlete</a>
|
|
|
|
|
</header>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Tile layer -->
|
|
|
|
|
<section class="section">
|
|
|
|
|
<p class="label">Map</p>
|
|
|
|
|
<div class="pills">
|
|
|
|
|
{#each TILE_ORDER as key}
|
|
|
|
|
<button class="pill" class:active={tileKey === key} onclick={() => setTile(key)}>{TILES[key].label}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- Type filter -->
|
|
|
|
|
<section class="section">
|
|
|
|
|
<div class="label-row">
|
|
|
|
|
<p class="label">Type</p>
|
|
|
|
|
<div class="label-actions">
|
|
|
|
|
<button class="mini-btn" onclick={selectAllTypes}>all</button>
|
|
|
|
|
<button class="mini-btn" onclick={clearAllTypes}>none</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="pills">
|
|
|
|
|
{#each allTypes as t}
|
|
|
|
|
<button class="pill type-pill" class:active={selectedTypes.has(t)} onclick={() => toggleType(t)}
|
|
|
|
|
style:--type-color={typeColor(t)}>
|
|
|
|
|
<span class="type-dot"></span>{t}
|
|
|
|
|
</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- Date filter -->
|
|
|
|
|
<section class="section">
|
|
|
|
|
<p class="label">Date</p>
|
|
|
|
|
<div class="pills year-pills">
|
|
|
|
|
<button class="pill" class:active={!dateFrom && !dateTo} onclick={clearDates}>All</button>
|
|
|
|
|
{#each availableYears as y}
|
|
|
|
|
<button class="pill" class:active={selectedYear === y} onclick={() => setYear(y)}>{y}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{#if selectedYear}
|
|
|
|
|
<div class="pills month-pills">
|
|
|
|
|
{#each MONTHS as m, i}
|
|
|
|
|
<button class="pill small"
|
|
|
|
|
class:active={dateFrom === `${selectedYear}-${String(i+1).padStart(2,'0')}-01`}
|
|
|
|
|
onclick={() => setMonth(i + 1)}>{m}</button>
|
|
|
|
|
{/each}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="date-inputs">
|
|
|
|
|
<input type="date" class="date-input" bind:value={dateFrom} placeholder="From" />
|
|
|
|
|
<input type="date" class="date-input" bind:value={dateTo} placeholder="To" />
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- View toggle -->
|
|
|
|
|
<section class="section">
|
|
|
|
|
<p class="label">View</p>
|
|
|
|
|
<div class="pills">
|
|
|
|
|
<button class="pill" class:active={viewMode === 'lines'} onclick={() => viewMode = 'lines'}>Lines</button>
|
|
|
|
|
<button class="pill" class:active={viewMode === 'heatmap'} onclick={() => viewMode = 'heatmap'}>Heatmap</button>
|
|
|
|
|
</div>
|
|
|
|
|
{#if viewMode === 'heatmap'}
|
|
|
|
|
<div class="pills" style="margin-top:0.375rem">
|
|
|
|
|
<button class="pill small" class:active={heatmapMode === 'global'} onclick={() => heatmapMode = 'global'}>Global</button>
|
|
|
|
|
<button class="pill small" class:active={heatmapMode === 'bytype'} onclick={() => heatmapMode = 'bytype'}>By type</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<!-- Stats -->
|
|
|
|
|
<section class="section">
|
|
|
|
|
<p class="stat-line">
|
|
|
|
|
<span class="stat-val">{filteredTracks.length}</span>
|
|
|
|
|
<span class="muted"> / {tracks.length} tracks</span>
|
|
|
|
|
</p>
|
|
|
|
|
{#if filteredTracks.length > 0}
|
|
|
|
|
<p class="stat-line muted small">
|
|
|
|
|
{(filteredTracks.reduce((s,t) => s + t.dist, 0) / 1000).toFixed(0)} km total
|
|
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
{#if loading}<p class="status">Loading tracks…</p>{/if}
|
|
|
|
|
{#if error}<p class="status error">{error}</p>{/if}
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
<!-- Map -->
|
|
|
|
|
<div class="map-wrap" bind:this={mapEl}></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.explore-layout {
|
|
|
|
|
display: flex;
|
|
|
|
|
height: 100%;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sidebar {
|
|
|
|
|
width: 240px;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
background: var(--bg-card);
|
|
|
|
|
border-right: 1px solid var(--border);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.sidebar-header {
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
|
|
|
|
.sidebar-title { font-size: 0.9rem; font-weight: 700; color: var(--text-primary); }
|
|
|
|
|
.back-link { font-size: 0.72rem; color: var(--text-5); text-decoration: none; }
|
|
|
|
|
.back-link:hover { color: var(--text-4); }
|
|
|
|
|
|
|
|
|
|
.section {
|
|
|
|
|
padding: 0.75rem 1rem;
|
|
|
|
|
border-bottom: 1px solid var(--border-sub, var(--border));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.label {
|
|
|
|
|
font-size: 0.68rem;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
color: var(--text-5);
|
|
|
|
|
margin: 0 0 0.45rem;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.45rem; }
|
|
|
|
|
.label-row .label { margin-bottom: 0; }
|
|
|
|
|
.label-actions { display: flex; gap: 0.25rem; }
|
|
|
|
|
.mini-btn { background: none; border: none; font-size: 0.65rem; color: var(--text-5); cursor: pointer; padding: 0 0.2rem; }
|
|
|
|
|
.mini-btn:hover { color: var(--text-4); }
|
|
|
|
|
|
|
|
|
|
.pills { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
|
|
|
|
.pill {
|
|
|
|
|
padding: 0.2rem 0.55rem;
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
background: transparent;
|
|
|
|
|
color: var(--text-4);
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
transition: all 0.12s;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
}
|
|
|
|
|
.pill:hover { border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
.pill.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
|
|
|
|
|
.pill.small { padding: 0.15rem 0.45rem; font-size: 0.67rem; }
|
|
|
|
|
|
|
|
|
|
.type-pill.active { background: color-mix(in srgb, var(--type-color) 15%, transparent); border-color: var(--type-color); color: var(--type-color); }
|
|
|
|
|
.type-pill:hover { border-color: var(--type-color); color: var(--type-color); }
|
|
|
|
|
.type-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--type-color); flex-shrink: 0; }
|
|
|
|
|
|
|
|
|
|
.year-pills { margin-bottom: 0.4rem; }
|
|
|
|
|
.month-pills { margin-bottom: 0.4rem; }
|
|
|
|
|
|
|
|
|
|
.date-inputs { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem; }
|
|
|
|
|
.date-input {
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 0.25rem 0.4rem;
|
|
|
|
|
border-radius: 0.3rem;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
background: var(--bg-elevated, var(--bg));
|
|
|
|
|
color: var(--text-primary);
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
.date-input:focus { outline: none; border-color: var(--accent); }
|
|
|
|
|
|
|
|
|
|
.stat-line { margin: 0 0 0.2rem; font-size: 0.8rem; }
|
|
|
|
|
.stat-val { font-weight: 600; color: var(--text-primary); }
|
|
|
|
|
|
|
|
|
|
.status { font-size: 0.72rem; color: var(--text-5); padding: 0.5rem 1rem 0; margin: 0; }
|
|
|
|
|
.status.error { color: #f87171; }
|
|
|
|
|
|
|
|
|
|
.muted { color: var(--text-5); }
|
|
|
|
|
.small { font-size: 0.72rem; margin: 0; }
|
|
|
|
|
|
|
|
|
|
.map-wrap { flex: 1; height: 100%; }
|
|
|
|
|
</style>
|