diff --git a/bincio/extract/simplify.py b/bincio/extract/simplify.py index de9488d..41b184d 100644 --- a/bincio/extract/simplify.py +++ b/bincio/extract/simplify.py @@ -2,11 +2,51 @@ from typing import Optional -from rdp import rdp - from bincio.extract.models import DataPoint +def _rdp_mask(coords: list[list[float]], epsilon: float) -> list[bool]: + """Pure-Python RDP — returns a boolean keep-mask of the same length as coords.""" + n = len(coords) + if n < 2: + return [True] * n + + mask = [False] * n + mask[0] = mask[-1] = True + + stack = [(0, n - 1)] + while stack: + start, end = stack.pop() + if end - start < 2: + continue + x1, y1 = coords[start] + x2, y2 = coords[end] + dx, dy = x2 - x1, y2 - y1 + seg_len_sq = dx * dx + dy * dy + + max_dist = -1.0 + max_idx = start + 1 + for i in range(start + 1, end): + x0, y0 = coords[i] + if seg_len_sq == 0: + d = ((x0 - x1) ** 2 + (y0 - y1) ** 2) ** 0.5 + else: + t = ((x0 - x1) * dx + (y0 - y1) * dy) / seg_len_sq + t = max(0.0, min(1.0, t)) + px, py = x1 + t * dx, y1 + t * dy + d = ((x0 - px) ** 2 + (y0 - py) ** 2) ** 0.5 + if d > max_dist: + max_dist = d + max_idx = i + + if max_dist >= epsilon: + mask[max_idx] = True + stack.append((start, max_idx)) + stack.append((max_idx, end)) + + return mask + + def simplify_track( points: list[DataPoint], epsilon: float = 0.0001, @@ -21,7 +61,7 @@ def simplify_track( return [p for p, _, _ in gps_pts] coords = [[lon, lat] for _, lat, lon in gps_pts] - mask = rdp(coords, epsilon=epsilon, return_mask=True) + mask = _rdp_mask(coords, epsilon=epsilon) return [p for (p, _, _), keep in zip(gps_pts, mask) if keep] @@ -40,7 +80,7 @@ def preview_coords( # Coarse RDP (larger epsilon = fewer points) coords = [[lon, lat] for lat, lon in gps] - mask = rdp(coords, epsilon=0.001, return_mask=True) + mask = _rdp_mask(coords, epsilon=0.001) reduced = [gps[i] for i, keep in enumerate(mask) if keep] # Subsample if still too many — always include last point without exceeding max_points diff --git a/pyproject.toml b/pyproject.toml index 6099e03..69dabb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,8 +18,6 @@ dependencies = [ "lxml>=5.0", # TCX (XML) # Data "pandas>=2.2", - # Geo - "rdp>=0.8", # Config & CLI "pyyaml>=6.0", "click>=8.1", diff --git a/site/public/sw.js b/site/public/sw.js new file mode 100644 index 0000000..df4cf7c --- /dev/null +++ b/site/public/sw.js @@ -0,0 +1,110 @@ +/** + * BincioActivity Service Worker + * + * Intercepts requests for /data/* and serves from IndexedDB when local + * activities are present, merging them with the bundled static index. + * + * IndexedDB schema: + * db: 'bincio', store: 'files' + * key: file path (e.g. '/data/activities/2024-01-01T120000Z-ride.json') + * value: { path, data } — data is the parsed JSON object + * + * Local activity summaries are kept under the special key '/data/local-index'. + * The SW merges these with the static index.json at request time so that + * activities from the server and from the device appear together in the feed. + */ + +const DB_NAME = 'bincio'; +const DB_VERSION = 1; +const STORE = 'files'; + +// ── IndexedDB helpers ───────────────────────────────────────────────────────── + +function openDB() { + return new Promise((resolve, reject) => { + const req = indexedDB.open(DB_NAME, DB_VERSION); + req.onupgradeneeded = e => { + e.target.result.createObjectStore(STORE, { keyPath: 'path' }); + }; + req.onsuccess = e => resolve(e.target.result); + req.onerror = e => reject(e.target.error); + }); +} + +async function idbGet(path) { + const db = await openDB(); + return new Promise((resolve, reject) => { + const req = db.transaction(STORE, 'readonly').objectStore(STORE).get(path); + req.onsuccess = e => resolve(e.target.result?.data ?? null); + req.onerror = e => reject(e.target.error); + }); +} + +// ── Fetch intercept ─────────────────────────────────────────────────────────── + +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', e => e.waitUntil(self.clients.claim())); + +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + + if (url.pathname === '/data/index.json') { + event.respondWith(handleIndex(event.request)); + return; + } + + if (url.pathname.startsWith('/data/activities/')) { + event.respondWith(handleActivity(url.pathname, event.request)); + return; + } +}); + +// Merge local summaries into the server/static index.json +async function handleIndex(request) { + try { + const localSummaries = (await idbGet('/data/local-index')) ?? []; + + if (localSummaries.length === 0) { + return fetch(request); // nothing local — pass straight through + } + + // Fetch the bundled static index (may fail if offline with no prior cache) + let remoteMeta = {}; + let remoteActivities = []; + try { + const r = await fetch(request); + const raw = await r.json(); + remoteActivities = raw.activities ?? []; + const { activities: _a, ...rest } = raw; + remoteMeta = rest; + } catch (_) {} + + // Local overrides remote for same ID; new local entries appended + const merged = new Map(); + for (const a of remoteActivities) merged.set(a.id, a); + for (const a of localSummaries) merged.set(a.id, a); + + const sorted = [...merged.values()].sort( + (a, b) => (b.started_at ?? '').localeCompare(a.started_at ?? '') + ); + + return jsonResponse({ ...remoteMeta, activities: sorted }); + } catch (_) { + return fetch(request); + } +} + +// Serve an individual activity file from IDB if present +async function handleActivity(path, request) { + try { + const local = await idbGet(path); + if (local !== null) return jsonResponse(local); + } catch (_) {} + return fetch(request); +} + +function jsonResponse(data) { + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); +} diff --git a/site/src/components/ActivityDetail.svelte b/site/src/components/ActivityDetail.svelte index e5f7014..bf16b09 100644 --- a/site/src/components/ActivityDetail.svelte +++ b/site/src/components/ActivityDetail.svelte @@ -7,6 +7,7 @@ import ActivityMap from './ActivityMap.svelte'; import ActivityCharts from './ActivityCharts.svelte'; import EditDrawer from './EditDrawer.svelte'; + import { loadActivity } from '../lib/dataloader'; export let activity: ActivitySummary; export let base: string = '/'; @@ -28,9 +29,8 @@ onMount(async () => { if (!activity.detail_url) return; try { - const res = await fetch(`${base}data/${activity.detail_url}`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - detail = await res.json(); + detail = await loadActivity(activity.id, activity.detail_url, base); + if (!detail) throw new Error('Activity not found'); } catch (e: any) { error = e.message; } diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte index 7390af8..a0e5240 100644 --- a/site/src/components/ActivityFeed.svelte +++ b/site/src/components/ActivityFeed.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import { formatDistance, formatDuration, formatElevation, formatDate, sportIcon, sportColor, sportLabel } from '../lib/format'; + import { loadIndex } from '../lib/dataloader'; /** Render preview_coords as an SVG polyline path string. */ function trackPath(coords: [number, number][] | null, w: number, h: number): string { @@ -53,9 +54,7 @@ sport = (new URLSearchParams(window.location.search).get('sport') as Sport | 'all') ?? 'all'; mounted = true; try { - const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const index: BASIndex = await res.json(); + const index = await loadIndex(import.meta.env.BASE_URL); all = index.activities.filter(a => a.privacy !== 'private'); } catch (e: any) { error = e.message; diff --git a/site/src/components/AthleteView.svelte b/site/src/components/AthleteView.svelte index d7b0b36..102e5e1 100644 --- a/site/src/components/AthleteView.svelte +++ b/site/src/components/AthleteView.svelte @@ -4,6 +4,7 @@ import MmpChart from './MmpChart.svelte'; import RecordsView from './RecordsView.svelte'; import AthleteDrawer from './AthleteDrawer.svelte'; + import { loadIndex, loadAthlete } from '../lib/dataloader'; export let base: string = '/'; @@ -32,13 +33,12 @@ activeTab = TABS.includes(rawTab as Tab) ? (rawTab as Tab) : 'power'; mounted = true; try { - const [athleteRes, indexRes] = await Promise.all([ - fetch(`${import.meta.env.BASE_URL}data/athlete.json`), - fetch(`${import.meta.env.BASE_URL}data/index.json`), + const [athleteData, index] = await Promise.all([ + loadAthlete(import.meta.env.BASE_URL), + loadIndex(import.meta.env.BASE_URL), ]); - if (!athleteRes.ok) throw new Error('athlete.json not found — run bincio extract first'); - athlete = await athleteRes.json(); - const index: BASIndex = await indexRes.json(); + if (!athleteData) throw new Error('athlete.json not found — run bincio extract first'); + athlete = athleteData; activities = index.activities.filter(a => a.mmp && a.privacy !== 'private'); } catch (e: any) { error = e.message; diff --git a/site/src/components/StatsView.svelte b/site/src/components/StatsView.svelte index d1bee17..be226a0 100644 --- a/site/src/components/StatsView.svelte +++ b/site/src/components/StatsView.svelte @@ -2,6 +2,7 @@ import { onMount } from 'svelte'; import type { ActivitySummary, BASIndex, Sport } from '../lib/types'; import { formatDistance, formatDuration, sportIcon, sportColor, sportLabel } from '../lib/format'; + import { loadIndex } from '../lib/dataloader'; const PAGE_YEARS = 4; @@ -30,9 +31,7 @@ page = parseInt(params.get('page') ?? '0', 10) || 0; mounted = true; try { - const res = await fetch(`${import.meta.env.BASE_URL}data/index.json`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const index: BASIndex = await res.json(); + const index = await loadIndex(import.meta.env.BASE_URL); all = index.activities.filter(a => a.privacy !== 'private' && a.distance_m); } catch (e: any) { error = e.message; diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index 346e2f4..5d78e83 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -208,6 +208,13 @@ const baseUrl = import.meta.env.BASE_URL ?? '/'; + +