#!/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.');