282 lines
9.1 KiB
JavaScript
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.');
|