diff --git a/scripts/generate-segments.js b/scripts/generate-segments.js new file mode 100644 index 0000000..e569f14 --- /dev/null +++ b/scripts/generate-segments.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node +// Mine all activities for popular overlapping stretches and pregenerate +// segments from the best ones (mirrors src/lib/server/segments.ts). +// +// node scripts/generate-segments.js [--db data/streba.db] [--limit 8] [--dry] + +import Database from 'better-sqlite3'; + +const args = process.argv.slice(2); +const dbPath = args.includes('--db') + ? args[args.indexOf('--db') + 1] + : (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db'; +const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1], 10) : 8; +const DRY = args.includes('--dry'); + +const CELL_DEG = 0.00028; +const MIN_ACTIVITIES = 5; +const MIN_STRETCH_M = 400; +const CORRIDOR_M = 45; +const SAMPLE_M = 30; + +const db = new Database(dbPath); +db.pragma('journal_mode = WAL'); +db.pragma('busy_timeout = 5000'); + +const R = 6371000; +function haversine(lat1, lon1, lat2, lon2) { + const rad = Math.PI / 180; + const dLat = (lat2 - lat1) * rad; + const dLon = (lon2 - lon1) * rad; + const a = + Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.sqrt(a)); +} +const cellKey = (lat, lon) => `${Math.round(lat / CELL_DEG)}:${Math.round(lon / CELL_DEG)}`; +function cumDist(pts) { + const d = [0]; + for (let i = 1; i < pts.length; i++) { + d.push(d[i - 1] + haversine(pts[i - 1].lat, pts[i - 1].lon, pts[i].lat, pts[i].lon)); + } + return d; +} + +// ---- mining -------------------------------------------------------------- +const rows = db.prepare('SELECT id, name, user_id, date, points, bounds FROM activities').all(); +console.log(`Scanning ${rows.length} activities…`); + +const cellCounts = new Map(); +const parsed = rows.map((row) => { + const points = JSON.parse(row.points); + const seen = new Set(); + for (const p of points) seen.add(cellKey(p.lat, p.lon)); + for (const key of seen) cellCounts.set(key, (cellCounts.get(key) ?? 0) + 1); + return { ...row, points }; +}); + +const claimed = new Set(); +for (const seg of db.prepare('SELECT points FROM segments').all()) { + for (const p of JSON.parse(seg.points)) claimed.add(cellKey(p.lat, p.lon)); +} + +const candidates = []; +for (const activity of parsed) { + const d = cumDist(activity.points); + let runStart = -1; + let gap = 0; + let counts = []; + const flush = (endIdx) => { + if (runStart < 0) return; + const length = d[endIdx] - d[runStart]; + if (length >= MIN_STRETCH_M) { + const cells = new Set(); + for (let i = runStart; i <= endIdx; i++) { + cells.add(cellKey(activity.points[i].lat, activity.points[i].lon)); + } + const avg = counts.reduce((s, c) => s + c, 0) / counts.length; + candidates.push({ + activity, + startIdx: runStart, + endIdx, + startD: d[runStart], + endD: d[endIdx], + length, + avg, + cells, + score: length * avg + }); + } + runStart = -1; + counts = []; + }; + for (let i = 0; i < activity.points.length; i++) { + const count = cellCounts.get(cellKey(activity.points[i].lat, activity.points[i].lon)) ?? 0; + if (count > MIN_ACTIVITIES && !claimed.has(cellKey(activity.points[i].lat, activity.points[i].lon))) { + if (runStart < 0) runStart = i; + gap = 0; + counts.push(count); + } else if (runStart >= 0 && ++gap > 4) { + flush(i - gap); + } + } + flush(activity.points.length - 1); +} + +candidates.sort((a, b) => b.score - a.score); +const accepted = []; +const used = new Set(); +for (const c of candidates) { + if (accepted.length >= LIMIT) break; + let overlap = 0; + for (const cell of c.cells) if (used.has(cell)) overlap++; + if (overlap / c.cells.size > 0.4) continue; + for (const cell of c.cells) used.add(cell); + accepted.push(c); +} +console.log(`Found ${candidates.length} candidate stretches, keeping top ${accepted.length}.`); + +// ---- naming -------------------------------------------------------------- +const nearestPeak = db.prepare( + `SELECT name FROM peaks + WHERE lat BETWEEN ? - 0.03 AND ? + 0.03 AND lon BETWEEN ? - 0.045 AND ? + 0.045 + ORDER BY ((lat - ?) * (lat - ?)) + ((lon - ?) * (lon - ?)) * 0.5 ASC LIMIT 1` +); +const usedNames = new Set( + db.prepare('SELECT name FROM segments').all().map((s) => s.name) +); +function direction(first, last) { + const dLat = last.lat - first.lat; + const dLon = (last.lon - first.lon) * Math.cos((first.lat * Math.PI) / 180); + return Math.abs(dLat) > Math.abs(dLon) ? (dLat > 0 ? 'North' : 'South') : dLon > 0 ? 'East' : 'West'; +} +function nameFor(candidate, slice) { + const first = slice[0]; + const last = slice[slice.length - 1]; + const gain = last.ele !== null && first.ele !== null ? last.ele - first.ele : 0; + const grade = (gain / candidate.length) * 100; + const kind = grade > 3 ? 'Climb' : grade < -3 ? 'Descent' : 'Stretch'; + const mid = slice[Math.floor(slice.length / 2)]; + const peak = nearestPeak.get(mid.lat, mid.lat, mid.lon, mid.lon, mid.lat, mid.lat, mid.lon, mid.lon); + const base = peak ? peak.name : candidate.activity.name; + let name = `${base} ${kind}`; + if (usedNames.has(name)) name = `${base} ${kind} ${direction(first, last)}`; + usedNames.add(name); + return name; +} + +// ---- creation + effort matching ----------------------------------------- +function checkpoints(points) { + const d = cumDist(points); + const out = [points[0]]; + let next = SAMPLE_M; + for (let i = 1; i < points.length - 1; i++) { + if (d[i] >= next) { + out.push(points[i]); + next = d[i] + SAMPLE_M; + } + } + out.push(points[points.length - 1]); + return out; +} +// mirrors computeEffort in src/lib/server/segments.ts (tolerant matcher) +function computeEffort(segPoints, actPoints) { + if (segPoints.length < 2 || actPoints.length < 2) return null; + const cps = checkpoints(segPoints); + const cpD = cumDist(cps); + const actD = cumDist(actPoints); + const start = cps[0]; + const maxMiss = Math.max(1, Math.floor(cps.length * 0.1)); + const cands = []; + for (let i = 0; i < actPoints.length; i++) { + if (haversine(actPoints[i].lat, actPoints[i].lon, start.lat, start.lon) <= CORRIDOR_M) { + if (cands.length === 0 || i - cands[cands.length - 1] > 15) cands.push(i); + } + } + let matched = false; + let best = null; + for (const s of cands) { + let j = s; + let misses = 0; + let ok = true; + for (let k = 1; k < cps.length; k++) { + const limit = actD[s] + cpD[k] * 1.5 + 150; + let found = -1; + for (let jj = j; jj < actPoints.length && actD[jj] <= limit; jj++) { + if (haversine(actPoints[jj].lat, actPoints[jj].lon, cps[k].lat, cps[k].lon) <= CORRIDOR_M) { + found = jj; + break; + } + } + if (found >= 0) { + j = found; + } else if (k === cps.length - 1 || ++misses > maxMiss) { + ok = false; + break; + } + } + if (!ok) continue; + matched = true; + const t0 = actPoints[s].t; + const t1 = actPoints[j].t; + if (t0 !== null && t1 !== null && t1 > t0) best = best === null ? t1 - t0 : Math.min(best, t1 - t0); + } + return matched ? { elapsed_s: best } : null; +} +function gainOf(points) { + let gain = 0; + let ref = null; + for (const p of points) { + if (p.ele === null) continue; + if (ref === null) ref = p.ele; + const diff = p.ele - ref; + if (diff >= 3) { + gain += diff; + ref = p.ele; + } else if (diff <= -3) ref = p.ele; + } + return gain; +} + +const insertSegment = db.prepare( + `INSERT INTO segments (name, creator_id, points, bounds, distance_m, elev_gain_m) + VALUES (?, NULL, ?, ?, ?, ?)` +); +const insertEffort = db.prepare( + `INSERT INTO segment_efforts (segment_id, activity_id, user_id, elapsed_s, date) + VALUES (?, ?, ?, ?, ?) ON CONFLICT (segment_id, activity_id) DO NOTHING` +); + +// --rematch: wipe and recompute every segment's efforts, then exit +if (args.includes('--rematch')) { + db.prepare('DELETE FROM segment_efforts').run(); + for (const segment of db.prepare('SELECT id, name, points FROM segments').all()) { + const segPoints = JSON.parse(segment.points); + let efforts = 0; + for (const activity of parsed) { + const effort = computeEffort(segPoints, activity.points); + if (effort) { + insertEffort.run(segment.id, activity.id, activity.user_id, effort.elapsed_s, activity.date); + efforts++; + } + } + console.log(`Rematched "${segment.name}" - ${efforts} efforts.`); + } + process.exit(0); +} + +for (const candidate of accepted) { + const slice = candidate.activity.points.slice(candidate.startIdx, candidate.endIdx + 1); + const name = nameFor(candidate, slice); + const km = (candidate.length / 1000).toFixed(1); + if (DRY) { + console.log(`[dry] would create "${name}" (${km} km, ~${Math.round(candidate.avg)} activities)`); + continue; + } + let minLat = Infinity, minLon = Infinity, maxLat = -Infinity, maxLon = -Infinity; + for (const p of slice) { + minLat = Math.min(minLat, p.lat); + maxLat = Math.max(maxLat, p.lat); + minLon = Math.min(minLon, p.lon); + maxLon = Math.max(maxLon, p.lon); + } + const result = insertSegment.run( + name, + JSON.stringify(slice.map((p) => ({ lat: p.lat, lon: p.lon, ele: p.ele, t: null }))), + JSON.stringify([[minLat, minLon], [maxLat, maxLon]]), + candidate.length, + gainOf(slice) + ); + const segmentId = Number(result.lastInsertRowid); + const segPoints = slice; + let efforts = 0; + for (const activity of parsed) { + const effort = computeEffort(segPoints, activity.points); + if (effort) { + insertEffort.run(segmentId, activity.id, activity.user_id, effort.elapsed_s, activity.date); + efforts++; + } + } + console.log(`Created "${name}" (${km} km) - ${efforts} efforts.`); +} +console.log('Done.'); diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 1551b41..b2f85bd 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -450,6 +450,51 @@ export function segmentLeaderboard(segmentId: number): { }[]; } +/** All segments a user has completed, with their best time, attempts, and rank. */ +export function userSegments(userId: number): { + id: number; + name: string; + distance_m: number; + elev_gain_m: number; + attempts: number; + best_s: number | null; + rank: number | null; +}[] { + return db + .prepare( + `SELECT s.id, s.name, s.distance_m, s.elev_gain_m, + count(e.id) attempts, + min(e.elapsed_s) best_s, + CASE WHEN min(e.elapsed_s) IS NULL THEN NULL ELSE ( + SELECT count(*) + 1 FROM ( + SELECT min(e2.elapsed_s) b FROM segment_efforts e2 + WHERE e2.segment_id = s.id AND e2.elapsed_s IS NOT NULL AND e2.user_id != @userId + GROUP BY e2.user_id + ) others WHERE others.b < min(e.elapsed_s) + ) END rank + FROM segments s JOIN segment_efforts e ON e.segment_id = s.id AND e.user_id = @userId + GROUP BY s.id ORDER BY attempts DESC, s.name` + ) + .all({ userId }) as { + id: number; + name: string; + distance_m: number; + elev_gain_m: number; + attempts: number; + best_s: number | null; + rank: number | null; + }[]; +} + +export function deleteSegment(id: number, userId: number): boolean { + // creators may delete their segments; system-generated ones (no creator) + // may be removed by any signed-in user + const result = db + .prepare('DELETE FROM segments WHERE id = ? AND (creator_id = ? OR creator_id IS NULL)') + .run(id, userId); + return result.changes > 0; +} + export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] { return db .prepare( diff --git a/src/lib/server/segments.ts b/src/lib/server/segments.ts index 79d1dd1..ba8ef84 100644 --- a/src/lib/server/segments.ts +++ b/src/lib/server/segments.ts @@ -37,7 +37,9 @@ function checkpoints(points: T[]): T[] { /** * Try to find the segment inside an activity: the activity must pass the - * segment start, then every checkpoint in order within the corridor. + * segment start, then the checkpoints in order within the corridor. A small + * fraction of checkpoints may be missed (GPS noise, brief detours), but the + * final checkpoint must be reached. Direction matters. * Returns the best (fastest) timed effort, an untimed match, or null. */ export function computeEffort( @@ -46,7 +48,10 @@ export function computeEffort( ): { elapsed_s: number | null } | null { if (segPoints.length < 2 || actPoints.length < 2) return null; const cps = checkpoints(segPoints); + const cpD = cumDist(cps); + const actD = cumDist(actPoints); const start = cps[0]; + const maxMiss = Math.max(1, Math.floor(cps.length * 0.1)); // candidate entries: activity points near the segment start (spaced apart to // catch repeat laps without re-testing every neighbouring point) @@ -61,20 +66,27 @@ export function computeEffort( let best: number | null = null; for (const s of candidates) { let j = s; + let misses = 0; let ok = true; for (let k = 1; k < cps.length; k++) { - while ( - j < actPoints.length && - haversine(actPoints[j].lat, actPoints[j].lon, cps[k].lat, cps[k].lon) > CORRIDOR_M - ) { - j++; + // only search as far along the activity as this checkpoint could + // plausibly be (with generous slack for wiggly tracks) + const limit = actD[s] + cpD[k] * 1.5 + 150; + let found = -1; + for (let jj = j; jj < actPoints.length && actD[jj] <= limit; jj++) { + if (haversine(actPoints[jj].lat, actPoints[jj].lon, cps[k].lat, cps[k].lon) <= CORRIDOR_M) { + found = jj; + break; + } } - if (j >= actPoints.length) { - ok = false; + if (found >= 0) { + j = found; + } else if (k === cps.length - 1 || ++misses > maxMiss) { + ok = false; // the finish itself may never be missed break; } } - if (!ok) break; // ran off the end of the activity; later candidates would too + if (!ok) continue; matched = true; const t0 = actPoints[s].t; const t1 = actPoints[j].t; diff --git a/src/routes/segments/[id]/+page.server.ts b/src/routes/segments/[id]/+page.server.ts index be49b0f..172338f 100644 --- a/src/routes/segments/[id]/+page.server.ts +++ b/src/routes/segments/[id]/+page.server.ts @@ -1,6 +1,6 @@ -import { error } from '@sveltejs/kit'; -import { getSegment, segmentLeaderboard } from '$lib/server/db'; -import type { PageServerLoad } from './$types'; +import { error, redirect } from '@sveltejs/kit'; +import { deleteSegment, getSegment, segmentLeaderboard } from '$lib/server/db'; +import type { Actions, PageServerLoad } from './$types'; import type { TrackPt } from '$lib/server/segments'; export const load: PageServerLoad = ({ params, locals }) => { @@ -12,6 +12,16 @@ export const load: PageServerLoad = ({ params, locals }) => { segment: { ...segment, points: undefined }, latlngs: points.map((p) => [p.lat, p.lon] as [number, number]), leaderboard: segmentLeaderboard(segment.id), - myUserId: locals.user?.id ?? null + myUserId: locals.user?.id ?? null, + canDelete: + !!locals.user && (segment.creator_id === locals.user.id || segment.creator_id === null) }; }; + +export const actions: Actions = { + delete: async ({ params, locals }) => { + if (!locals.user) error(401, 'Not signed in'); + deleteSegment(Number(params.id), locals.user.id); + redirect(303, '/segments'); + } +}; diff --git a/src/routes/segments/[id]/+page.svelte b/src/routes/segments/[id]/+page.svelte index 4cb0d51..90077a5 100644 --- a/src/routes/segments/[id]/+page.svelte +++ b/src/routes/segments/[id]/+page.svelte @@ -1,4 +1,5 @@