Files
streba/scripts/generate-segments.js

282 lines
9.1 KiB
JavaScript

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