Compare commits
18
Commits
b89de57967
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4512d3550 | ||
|
|
512860c863 | ||
|
|
23a2b4b374 | ||
|
|
e59340d33d | ||
|
|
2c82445291 | ||
|
|
da9f7c6e1a | ||
|
|
91775c4099 | ||
|
|
a24eef807c | ||
|
|
279469cd65 | ||
|
|
3d97c118b4 | ||
|
|
e4412078ea | ||
|
|
244359474d | ||
|
|
f42caad93b | ||
|
|
3919ac9eea | ||
|
|
5f526a46ce | ||
|
|
cc9037c922 | ||
|
|
5937cedcc5 | ||
|
|
ce073f3fee |
@@ -43,6 +43,10 @@ node build
|
|||||||
Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
|
Set `STREBA_DATA_DIR` to move the SQLite database somewhere else (defaults to
|
||||||
`./data`).
|
`./data`).
|
||||||
|
|
||||||
|
Signup is protected by a self-hosted image captcha plus a honeypot field.
|
||||||
|
For scripted signups (tests, provisioning) set `STREBA_CAPTCHA_BYPASS` to a
|
||||||
|
secret value and submit it as the captcha answer.
|
||||||
|
|
||||||
## Peaks data
|
## Peaks data
|
||||||
|
|
||||||
A fresh database is seeded with ~80 curated famous peaks so the app works out
|
A fresh database is seeded with ~80 curated famous peaks so the app works out
|
||||||
|
|||||||
Generated
+32
-1
@@ -10,7 +10,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"fast-xml-parser": "^5.10.1",
|
"fast-xml-parser": "^5.10.1",
|
||||||
"maplibre-gl": "^5.24.0"
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
@@ -2183,6 +2184,18 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/opentype.js": {
|
||||||
|
"version": "0.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-0.7.3.tgz",
|
||||||
|
"integrity": "sha512-Veui5vl2bLonFJ/SjX/WRWJT3SncgiZNnKUyahmXCc2sa1xXW15u3R/3TN5+JFiP7RsjK5ER4HA5eWaEmV9deA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tiny-inflate": "^1.0.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"ot": "bin/ot"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-expression-matcher": {
|
"node_modules/path-expression-matcher": {
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
@@ -2705,6 +2718,18 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/svg-captcha": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/svg-captcha/-/svg-captcha-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-/fkkhavXPE57zRRCjNqAP3txRCSncpMx3NnNZL7iEoyAtYwUjPhJxW6FQTQPG5UPEmCrbFoXS10C3YdJlW7PDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"opentype.js": "^0.7.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tar-fs": {
|
"node_modules/tar-fs": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
|
||||||
@@ -2733,6 +2758,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-inflate": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
|
|||||||
+2
-1
@@ -13,7 +13,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"fast-xml-parser": "^5.10.1",
|
"fast-xml-parser": "^5.10.1",
|
||||||
"maplibre-gl": "^5.24.0"
|
"maplibre-gl": "^5.24.0",
|
||||||
|
"svg-captcha": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource-variable/inter": "^5.3.0",
|
"@fontsource-variable/inter": "^5.3.0",
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Audit activity types against their measured stats and fix contradictions.
|
||||||
|
//
|
||||||
|
// node scripts/audit-types.js # dry run: show proposed changes
|
||||||
|
// node scripts/audit-types.js --apply # apply + log to data/type-fixes.json
|
||||||
|
//
|
||||||
|
// Custom/curated types (skitour, vita, iceskate, …) are left alone; only
|
||||||
|
// clear physics contradictions in the generic foot/bike types plus the
|
||||||
|
// "outdoor" fallback are reclassified, using centroids computed from the
|
||||||
|
// database's own labelled activities.
|
||||||
|
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const APPLY = process.argv.includes('--apply');
|
||||||
|
const dbPath = (process.env.STREBA_DATA_DIR ?? 'data') + '/streba.db';
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT id, name, type, distance_m, elev_gain_m,
|
||||||
|
coalesce(moving_s, duration_s) time_s
|
||||||
|
FROM activities`
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
.map((r) => ({
|
||||||
|
...r,
|
||||||
|
kmh: r.time_s > 0 ? (r.distance_m / r.time_s) * 3.6 : null,
|
||||||
|
gainPerKm: r.distance_m > 0 ? r.elev_gain_m / (r.distance_m / 1000) : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// generic classes we are willing to assign
|
||||||
|
const FOOT_SLOW = new Set(['hike', 'walk', 'walking']);
|
||||||
|
const FOOT_FAST = new Set(['run', 'running', 'trail run']);
|
||||||
|
const BIKE = new Set(['ride', 'cycling', 'bike', 'mtb', 'gravel']);
|
||||||
|
const GENERIC = new Set([...FOOT_SLOW, ...FOOT_FAST, ...BIKE, 'outdoor']);
|
||||||
|
|
||||||
|
// centroids from the user's own labelled data (fall back to sensible defaults)
|
||||||
|
function centroid(types, defKmh, defGain, defKm) {
|
||||||
|
const sample = rows.filter((r) => types.has(r.type) && r.kmh !== null);
|
||||||
|
if (sample.length < 3) return { kmh: defKmh, gainPerKm: defGain, km: defKm };
|
||||||
|
return {
|
||||||
|
kmh: sample.reduce((s, r) => s + r.kmh, 0) / sample.length,
|
||||||
|
gainPerKm: sample.reduce((s, r) => s + r.gainPerKm, 0) / sample.length,
|
||||||
|
km: sample.reduce((s, r) => s + r.distance_m / 1000, 0) / sample.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const CLASSES = [
|
||||||
|
{ name: 'hike', c: centroid(FOOT_SLOW, 4, 70, 10) },
|
||||||
|
{ name: 'run', c: centroid(FOOT_FAST, 11, 25, 8) },
|
||||||
|
{ name: 'ride', c: centroid(new Set(['ride', 'cycling', 'bike']), 21, 15, 40) },
|
||||||
|
{ name: 'mtb', c: centroid(new Set(['mtb']), 13.5, 45, 27) }
|
||||||
|
];
|
||||||
|
function classify(r) {
|
||||||
|
if (r.kmh === null) return null;
|
||||||
|
let best = null;
|
||||||
|
let bestScore = Infinity;
|
||||||
|
for (const cls of CLASSES) {
|
||||||
|
// normalised distance in (speed, climb-rate, length) space
|
||||||
|
const ds = (r.kmh - cls.c.kmh) / 6;
|
||||||
|
const dg = (r.gainPerKm - cls.c.gainPerKm) / 40;
|
||||||
|
const dd = (r.distance_m / 1000 - cls.c.km) / 20;
|
||||||
|
const score = ds * ds + dg * dg + dd * dd;
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
best = cls.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
function contradiction(r) {
|
||||||
|
if (r.kmh === null) return false;
|
||||||
|
if (r.type === 'outdoor') return true;
|
||||||
|
if (/^\d+$/.test(r.type)) return true;
|
||||||
|
if (FOOT_SLOW.has(r.type) && r.kmh > 9) return true;
|
||||||
|
if (FOOT_FAST.has(r.type) && (r.kmh < 4.5 || r.kmh > 16)) return true;
|
||||||
|
if (BIKE.has(r.type) && r.kmh < 5.5) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = [];
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!GENERIC.has(r.type) && !/^\d+$/.test(r.type)) continue; // curated type: hands off
|
||||||
|
if (!contradiction(r)) continue;
|
||||||
|
const suggestion = classify(r);
|
||||||
|
if (!suggestion || suggestion === r.type) continue;
|
||||||
|
changes.push({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
from: r.type,
|
||||||
|
to: suggestion,
|
||||||
|
kmh: r.kmh === null ? null : Math.round(r.kmh * 10) / 10,
|
||||||
|
gainPerKm: Math.round(r.gainPerKm)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// borderline cases worth a human look (reported, never auto-changed)
|
||||||
|
const review = rows.filter(
|
||||||
|
(r) =>
|
||||||
|
(r.type === 'ride' && r.gainPerKm > 35 && r.kmh < 16) ||
|
||||||
|
(r.type === 'mtb' && r.gainPerKm < 12 && r.kmh > 17) ||
|
||||||
|
(FOOT_FAST.has(r.type) && r.kmh !== null && r.kmh < 6) ||
|
||||||
|
(FOOT_SLOW.has(r.type) && r.kmh !== null && r.kmh > 7 && r.kmh <= 9)
|
||||||
|
);
|
||||||
|
if (review.length > 0) {
|
||||||
|
console.log('\nBorderline (left unchanged - review by hand if they look off):');
|
||||||
|
console.table(
|
||||||
|
review.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
type: r.type,
|
||||||
|
kmh: Math.round((r.kmh ?? 0) * 10) / 10,
|
||||||
|
gainPerKm: Math.round(r.gainPerKm),
|
||||||
|
km: Math.round(r.distance_m / 100) / 10
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
console.log('No contradictions found.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
console.table(changes);
|
||||||
|
if (!APPLY) {
|
||||||
|
console.log(`Dry run - ${changes.length} change(s) proposed. Re-run with --apply.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = db.prepare('UPDATE activities SET type = ? WHERE id = ?');
|
||||||
|
db.transaction(() => {
|
||||||
|
for (const change of changes) update.run(change.to, change.id);
|
||||||
|
})();
|
||||||
|
fs.writeFileSync(
|
||||||
|
(process.env.STREBA_DATA_DIR ?? 'data') + '/type-fixes.json',
|
||||||
|
JSON.stringify({ applied: new Date().toISOString(), changes }, null, 2)
|
||||||
|
);
|
||||||
|
console.log(`Applied ${changes.length} change(s); log written to data/type-fixes.json.`);
|
||||||
@@ -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.');
|
||||||
+63
@@ -20,6 +20,14 @@
|
|||||||
--logo-wash-2: #96d6c6;
|
--logo-wash-2: #96d6c6;
|
||||||
--logo-text: #56555a;
|
--logo-text: #56555a;
|
||||||
--map-icon-filter: none;
|
--map-icon-filter: none;
|
||||||
|
--cat-0: #2a78d6;
|
||||||
|
--cat-1: #eb6834;
|
||||||
|
--cat-2: #1baf7a;
|
||||||
|
--cat-3: #eda100;
|
||||||
|
--cat-4: #e87ba4;
|
||||||
|
--cat-5: #008300;
|
||||||
|
--cat-6: #4a3aa7;
|
||||||
|
--cat-7: #e34948;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* dark tokens apply when the OS prefers dark (unless the user forced light),
|
/* dark tokens apply when the OS prefers dark (unless the user forced light),
|
||||||
@@ -46,6 +54,14 @@
|
|||||||
--logo-wash-2: #1e6355;
|
--logo-wash-2: #1e6355;
|
||||||
--logo-text: #eef2f0;
|
--logo-text: #eef2f0;
|
||||||
--map-icon-filter: invert(1) brightness(1.1);
|
--map-icon-filter: invert(1) brightness(1.1);
|
||||||
|
--cat-0: #3987e5;
|
||||||
|
--cat-1: #d95926;
|
||||||
|
--cat-2: #199e70;
|
||||||
|
--cat-3: #c98500;
|
||||||
|
--cat-4: #d55181;
|
||||||
|
--cat-5: #008300;
|
||||||
|
--cat-6: #9085e9;
|
||||||
|
--cat-7: #e66767;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
:root[data-theme='dark'] {
|
:root[data-theme='dark'] {
|
||||||
@@ -69,12 +85,56 @@
|
|||||||
--logo-wash-2: #1e6355;
|
--logo-wash-2: #1e6355;
|
||||||
--logo-text: #eef2f0;
|
--logo-text: #eef2f0;
|
||||||
--map-icon-filter: invert(1) brightness(1.1);
|
--map-icon-filter: invert(1) brightness(1.1);
|
||||||
|
--cat-0: #3987e5;
|
||||||
|
--cat-1: #d95926;
|
||||||
|
--cat-2: #199e70;
|
||||||
|
--cat-3: #c98500;
|
||||||
|
--cat-4: #d55181;
|
||||||
|
--cat-5: #008300;
|
||||||
|
--cat-6: #9085e9;
|
||||||
|
--cat-7: #e66767;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
/* reserve the scrollbar gutter so page changes never shift content sideways */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* page transitions: gentle simultaneous cross-fade for content;
|
||||||
|
the map morphs separately; topbar and footer are pinned and never fade */
|
||||||
|
@media not (prefers-reduced-motion: reduce) {
|
||||||
|
::view-transition-old(root) {
|
||||||
|
animation: 160ms ease both vt-fade-out;
|
||||||
|
}
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: 160ms ease both vt-fade-in;
|
||||||
|
}
|
||||||
|
::view-transition-group(streba-map) {
|
||||||
|
animation-duration: 320ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.3, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
::view-transition-old(topbar),
|
||||||
|
::view-transition-new(topbar),
|
||||||
|
::view-transition-old(site-footer),
|
||||||
|
::view-transition-new(site-footer) {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes vt-fade-out {
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes vt-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--page);
|
background: var(--page);
|
||||||
@@ -83,6 +143,9 @@ body {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
|
|||||||
+3
-1
@@ -9,7 +9,9 @@ function isPublic(path: string): boolean {
|
|||||||
AUTH_PATHS.has(path) ||
|
AUTH_PATHS.has(path) ||
|
||||||
/^\/activities\/\d+$/.test(path) ||
|
/^\/activities\/\d+$/.test(path) ||
|
||||||
path.startsWith('/users/') ||
|
path.startsWith('/users/') ||
|
||||||
path.startsWith('/avatars/')
|
path.startsWith('/avatars/') ||
|
||||||
|
path === '/segments' ||
|
||||||
|
/^\/segments\/\d+$/.test(path)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Categorical palette slots (validated for light and dark surfaces).
|
||||||
|
// Activity types map to fixed slots so a type keeps its color everywhere.
|
||||||
|
|
||||||
|
export const CAT_SLOTS = [
|
||||||
|
{ light: '#2a78d6', dark: '#3987e5' }, // blue
|
||||||
|
{ light: '#eb6834', dark: '#d95926' }, // orange
|
||||||
|
{ light: '#1baf7a', dark: '#199e70' }, // aqua
|
||||||
|
{ light: '#eda100', dark: '#c98500' }, // yellow
|
||||||
|
{ light: '#e87ba4', dark: '#d55181' }, // magenta
|
||||||
|
{ light: '#008300', dark: '#008300' }, // green
|
||||||
|
{ light: '#4a3aa7', dark: '#9085e9' }, // violet
|
||||||
|
{ light: '#e34948', dark: '#e66767' } // red
|
||||||
|
];
|
||||||
|
|
||||||
|
const TYPE_SLOT: Record<string, number> = {
|
||||||
|
hiking: 0,
|
||||||
|
hike: 0,
|
||||||
|
walk: 0,
|
||||||
|
walking: 0,
|
||||||
|
run: 1,
|
||||||
|
running: 1,
|
||||||
|
'trail run': 1,
|
||||||
|
ride: 2,
|
||||||
|
cycling: 2,
|
||||||
|
bike: 2,
|
||||||
|
biking: 2,
|
||||||
|
'virtual ride': 2,
|
||||||
|
'mountain bike': 2,
|
||||||
|
climb: 3,
|
||||||
|
climbing: 3,
|
||||||
|
'via ferrata': 3,
|
||||||
|
alpinism: 3,
|
||||||
|
ski: 6,
|
||||||
|
alpineski: 6,
|
||||||
|
skitour: 6,
|
||||||
|
langlauf: 6,
|
||||||
|
'backcountry ski': 6,
|
||||||
|
'nordic ski': 6,
|
||||||
|
snowboard: 6,
|
||||||
|
snowshoe: 6,
|
||||||
|
mtb: 2,
|
||||||
|
gravel: 2,
|
||||||
|
vita: 1,
|
||||||
|
iceskate: 4,
|
||||||
|
inlineskate: 4,
|
||||||
|
inline: 4,
|
||||||
|
'hike & fly': 3,
|
||||||
|
watersport: 5,
|
||||||
|
segment: 7
|
||||||
|
};
|
||||||
|
|
||||||
|
function hash(s: string): number {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
|
||||||
|
return Math.abs(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeSlot(type: string): number {
|
||||||
|
const t = type.toLowerCase().trim();
|
||||||
|
return TYPE_SLOT[t] ?? hash(t) % CAT_SLOTS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function typeColor(type: string): { light: string; dark: string } {
|
||||||
|
return CAT_SLOTS[typeSlot(type)];
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Lift-assisted downhill types: their recorded elevation gain comes from
|
||||||
|
// lifts, so it doesn't count toward ascent totals. Touring variants
|
||||||
|
// ("skitour", "backcountry ski", "langlauf") earn their metres and do count.
|
||||||
|
export const LIFT_ASSISTED_TYPES = [
|
||||||
|
'ski',
|
||||||
|
'alpine ski',
|
||||||
|
'alpineski',
|
||||||
|
'downhill ski',
|
||||||
|
'snowboard'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function countsTowardAscent(type: string): boolean {
|
||||||
|
return !LIFT_ASSISTED_TYPES.includes(type.toLowerCase().trim());
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDate, fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||||
|
import { typeSlot } from '$lib/activity-colors';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
activity
|
activity
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<a class="item" href="/activities/{activity.id}">
|
<a class="item" href="/activities/{activity.id}">
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="name">{activity.name}</span>
|
<span class="name">{activity.name}</span>
|
||||||
<span class="type">{activity.type}</span>
|
<span class="type" style="--tc: var(--cat-{typeSlot(activity.type)})">{activity.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
{#if activity.username}
|
{#if activity.username}
|
||||||
@@ -61,9 +62,9 @@
|
|||||||
}
|
}
|
||||||
.type {
|
.type {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: var(--accent-strong);
|
color: var(--tc, var(--accent-strong));
|
||||||
background: var(--accent-wash);
|
background: color-mix(in srgb, var(--tc, var(--accent)) 13%, transparent);
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem 0.5rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { profile }: { profile: { d: number; ele: number }[] } = $props();
|
let {
|
||||||
|
profile,
|
||||||
|
hoverD = null,
|
||||||
|
onhover,
|
||||||
|
onselect
|
||||||
|
}: {
|
||||||
|
profile: { d: number; ele: number }[];
|
||||||
|
/** shared hover distance controlled by the page, so charts stay in sync */
|
||||||
|
hoverD?: number | null;
|
||||||
|
onhover?: (d: number | null) => void;
|
||||||
|
onselect?: (d: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let width = $state(720);
|
let width = $state(720);
|
||||||
const height = 220;
|
const height = 220;
|
||||||
@@ -41,21 +52,23 @@
|
|||||||
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
`${linePath}L${x(totalDist).toFixed(1)},${height - pad.bottom}L${pad.left},${height - pad.bottom}Z`
|
||||||
);
|
);
|
||||||
|
|
||||||
let hover = $state<{ d: number; ele: number } | null>(null);
|
// snap the shared hover distance to this chart's nearest point
|
||||||
|
const hover = $derived.by(() => {
|
||||||
function onmove(event: PointerEvent) {
|
if (hoverD === null || profile.length === 0) return null;
|
||||||
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
|
||||||
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
|
||||||
const target = frac * totalDist;
|
|
||||||
// binary search closest point
|
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
let hi = profile.length - 1;
|
let hi = profile.length - 1;
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
const mid = (lo + hi) >> 1;
|
const mid = (lo + hi) >> 1;
|
||||||
if (profile[mid].d < target) lo = mid;
|
if (profile[mid].d < hoverD) lo = mid;
|
||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - profile[lo].d < profile[hi].d - target ? profile[lo] : profile[hi];
|
return hoverD - profile[lo].d < profile[hi].d - hoverD ? profile[lo] : profile[hi];
|
||||||
|
});
|
||||||
|
|
||||||
|
function onmove(event: PointerEvent) {
|
||||||
|
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
||||||
|
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
onhover?.(frac * totalDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||||
@@ -95,7 +108,8 @@
|
|||||||
height={height - pad.top - pad.bottom}
|
height={height - pad.top - pad.bottom}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onpointerleave={() => (hover = null)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
|
onpointerleave={() => onhover?.(null)}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
+401
-55
@@ -2,11 +2,17 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
|
import { typeColor } from '$lib/activity-colors';
|
||||||
|
import { fmtDate, fmtDistance } from '$lib/format';
|
||||||
import type { FeatureCollection, Point } from 'geojson';
|
import type { FeatureCollection, Point } from 'geojson';
|
||||||
|
|
||||||
export interface MapTrack {
|
export interface MapTrack {
|
||||||
id?: number;
|
id?: number;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
type?: string;
|
||||||
|
date?: string | null;
|
||||||
|
distance_m?: number;
|
||||||
latlngs: [number, number][];
|
latlngs: [number, number][];
|
||||||
}
|
}
|
||||||
export interface MapPeak {
|
export interface MapPeak {
|
||||||
@@ -32,12 +38,20 @@
|
|||||||
let {
|
let {
|
||||||
tracks = [],
|
tracks = [],
|
||||||
peaks = [],
|
peaks = [],
|
||||||
height = '420px',
|
highlightId = null,
|
||||||
|
focus = null,
|
||||||
|
hoverPoint = null,
|
||||||
|
selection = null,
|
||||||
|
height = '100%',
|
||||||
onpeakclick,
|
onpeakclick,
|
||||||
onviewport
|
onviewport
|
||||||
}: {
|
}: {
|
||||||
tracks?: MapTrack[];
|
tracks?: MapTrack[];
|
||||||
peaks?: MapPeak[];
|
peaks?: MapPeak[];
|
||||||
|
highlightId?: number | null;
|
||||||
|
focus?: [[number, number], [number, number]] | null;
|
||||||
|
hoverPoint?: { lat: number; lon: number } | null;
|
||||||
|
selection?: [number, number][] | null;
|
||||||
height?: string;
|
height?: string;
|
||||||
onpeakclick?: (id: number) => void;
|
onpeakclick?: (id: number) => void;
|
||||||
onviewport?: (view: Viewport) => void;
|
onviewport?: (view: Viewport) => void;
|
||||||
@@ -45,7 +59,7 @@
|
|||||||
|
|
||||||
let container: HTMLDivElement;
|
let container: HTMLDivElement;
|
||||||
let map: import('maplibre-gl').Map | undefined;
|
let map: import('maplibre-gl').Map | undefined;
|
||||||
let lib: typeof import('maplibre-gl') | undefined;
|
let loaded = $state(false);
|
||||||
let currentDark: boolean | undefined;
|
let currentDark: boolean | undefined;
|
||||||
let pendingTerrain: unknown = null;
|
let pendingTerrain: unknown = null;
|
||||||
|
|
||||||
@@ -53,6 +67,12 @@
|
|||||||
map?.flyTo({ center: [lon, lat], zoom });
|
map?.flyTo({ center: [lon, lat], zoom });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reportFn: (() => void) | null = null;
|
||||||
|
/** Ask the map to re-announce its current viewport (used when a page starts listening). */
|
||||||
|
export function reportViewport() {
|
||||||
|
if (loaded) reportFn?.();
|
||||||
|
}
|
||||||
|
|
||||||
function peaksGeojson(): FeatureCollection {
|
function peaksGeojson(): FeatureCollection {
|
||||||
return {
|
return {
|
||||||
type: 'FeatureCollection',
|
type: 'FeatureCollection',
|
||||||
@@ -70,11 +90,187 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// push new data into the peaks layer when peaks/climbed state changes
|
function tracksGeojson(dark: boolean): FeatureCollection {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: tracks.map((track) => {
|
||||||
|
const color = typeColor(track.type ?? 'outdoor');
|
||||||
|
return {
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: {
|
||||||
|
id: track.id ?? null,
|
||||||
|
name: track.name ?? '',
|
||||||
|
username: track.username ?? '',
|
||||||
|
type: track.type ?? '',
|
||||||
|
date: track.date ?? '',
|
||||||
|
distance_m: track.distance_m ?? 0,
|
||||||
|
color: dark ? color.dark : color.light,
|
||||||
|
dim: highlightId !== null && track.id !== highlightId,
|
||||||
|
highlight: highlightId !== null && track.id === highlightId
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString' as const,
|
||||||
|
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function overviewBounds(): [[number, number], [number, number]] | null {
|
||||||
|
let minLat = Infinity,
|
||||||
|
minLon = Infinity,
|
||||||
|
maxLat = -Infinity,
|
||||||
|
maxLon = -Infinity;
|
||||||
|
for (const track of tracks) {
|
||||||
|
for (const [lat, lon] of track.latlngs) {
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
if (lon < minLon) minLon = lon;
|
||||||
|
if (lon > maxLon) maxLon = lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Number.isFinite(minLat)
|
||||||
|
? [
|
||||||
|
[minLat, minLon],
|
||||||
|
[maxLat, maxLon]
|
||||||
|
]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCamera(animate: boolean) {
|
||||||
|
if (!map) return;
|
||||||
|
const target = focus ?? overviewBounds();
|
||||||
|
if (!target) return;
|
||||||
|
map.fitBounds(
|
||||||
|
[
|
||||||
|
[target[0][1], target[0][0]],
|
||||||
|
[target[1][1], target[1][0]]
|
||||||
|
],
|
||||||
|
{ padding: 48, maxZoom: 14, duration: animate ? 1100 : 0 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep sources in sync when data, highlight, or theme changes
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void peaks;
|
void peaks;
|
||||||
const source = map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined;
|
if (!loaded) return;
|
||||||
source?.setData(peaksGeojson());
|
(map?.getSource('peaks') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
peaksGeojson()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
void tracks;
|
||||||
|
void highlightId;
|
||||||
|
if (!loaded) return;
|
||||||
|
(map?.getSource('tracks') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
tracksGeojson(currentDark ?? false)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
function hoverGeojson(): FeatureCollection {
|
||||||
|
// the marker wears the highlighted activity's type color
|
||||||
|
const track = highlightId !== null ? tracks.find((t) => t.id === highlightId) : undefined;
|
||||||
|
const color = typeColor(track?.type ?? 'outdoor');
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: hoverPoint
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: { color: currentDark ? color.dark : color.light },
|
||||||
|
geometry: { type: 'Point', coordinates: [hoverPoint.lon, hoverPoint.lat] }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
void hoverPoint;
|
||||||
|
if (!loaded) return;
|
||||||
|
(map?.getSource('hover-point') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
hoverGeojson()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
function selectionGeojson(): FeatureCollection {
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features:
|
||||||
|
selection && selection.length > 1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
type: 'Feature',
|
||||||
|
properties: {},
|
||||||
|
geometry: {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: selection.map(([lat, lon]) => [lon, lat])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
void selection;
|
||||||
|
if (!loaded) return;
|
||||||
|
(map?.getSource('selection') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
selectionGeojson()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// start/finish markers for the highlighted route or the live selection
|
||||||
|
function endpointsGeojson(): FeatureCollection {
|
||||||
|
const line =
|
||||||
|
selection && selection.length > 1
|
||||||
|
? selection
|
||||||
|
: highlightId !== null
|
||||||
|
? tracks.find((t) => t.id === highlightId)?.latlngs
|
||||||
|
: undefined;
|
||||||
|
if (!line || line.length < 2) return { type: 'FeatureCollection', features: [] };
|
||||||
|
const make = (kind: string, [lat, lon]: [number, number]) => ({
|
||||||
|
type: 'Feature' as const,
|
||||||
|
properties: { kind },
|
||||||
|
geometry: { type: 'Point' as const, coordinates: [lon, lat] }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: [make('start', line[0]), make('end', line[line.length - 1])]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
$effect(() => {
|
||||||
|
void selection;
|
||||||
|
void tracks;
|
||||||
|
void highlightId;
|
||||||
|
if (!loaded) return;
|
||||||
|
(map?.getSource('endpoints') as import('maplibre-gl').GeoJSONSource | undefined)?.setData(
|
||||||
|
endpointsGeojson()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// animate the camera when a page requests a new focus; remember the
|
||||||
|
// unfocused camera so returning to the overview restores it instead of
|
||||||
|
// zooming all the way back out
|
||||||
|
let lastFocusKey: string | undefined;
|
||||||
|
let savedCamera: { center: import('maplibre-gl').LngLat; zoom: number } | null = null;
|
||||||
|
$effect(() => {
|
||||||
|
const focused = focus !== null;
|
||||||
|
const key = JSON.stringify(focus);
|
||||||
|
if (!loaded || !map) return;
|
||||||
|
if (lastFocusKey === undefined) {
|
||||||
|
lastFocusKey = key;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === lastFocusKey) return;
|
||||||
|
const wasFocused = lastFocusKey !== 'null';
|
||||||
|
lastFocusKey = key;
|
||||||
|
if (focused) {
|
||||||
|
if (!wasFocused) savedCamera = { center: map.getCenter(), zoom: map.getZoom() };
|
||||||
|
applyCamera(true);
|
||||||
|
} else if (savedCamera) {
|
||||||
|
map.flyTo({ center: savedCamera.center, zoom: savedCamera.zoom, duration: 1100 });
|
||||||
|
savedCamera = null;
|
||||||
|
} else {
|
||||||
|
applyCamera(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// swap map style when the theme changes
|
// swap map style when the theme changes
|
||||||
@@ -111,33 +307,45 @@
|
|||||||
firstSymbol
|
firstSymbol
|
||||||
);
|
);
|
||||||
|
|
||||||
map.addSource('tracks', {
|
map.addSource('tracks', { type: 'geojson', data: tracksGeojson(dark) });
|
||||||
type: 'geojson',
|
// soft glow behind the highlighted route so it pops off the basemap
|
||||||
data: {
|
map.addLayer({
|
||||||
type: 'FeatureCollection',
|
id: 'tracks-halo',
|
||||||
features: tracks.map((track) => ({
|
type: 'line',
|
||||||
type: 'Feature',
|
source: 'tracks',
|
||||||
properties: { name: track.name ?? '' },
|
filter: ['==', ['get', 'highlight'], true],
|
||||||
geometry: {
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
type: 'LineString',
|
paint: { 'line-color': ['get', 'color'], 'line-width': 14, 'line-opacity': 0.22 }
|
||||||
coordinates: track.latlngs.map(([lat, lon]) => [lon, lat])
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'tracks-casing',
|
id: 'tracks-casing',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'tracks',
|
source: 'tracks',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
paint: { 'line-color': dark ? '#1a1a19' : '#ffffff', 'line-width': 5, 'line-opacity': 0.6 }
|
paint: {
|
||||||
|
'line-color': dark ? '#1a1a19' : '#ffffff',
|
||||||
|
'line-width': ['case', ['get', 'highlight'], 8, 5],
|
||||||
|
'line-opacity': ['case', ['get', 'dim'], 0.08, ['get', 'highlight'], 0.95, 0.6]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
id: 'tracks-line',
|
id: 'tracks-line',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: 'tracks',
|
source: 'tracks',
|
||||||
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
paint: { 'line-color': dark ? '#3987e5' : '#2a78d6', 'line-width': 2.5 }
|
paint: {
|
||||||
|
'line-color': ['get', 'color'],
|
||||||
|
'line-width': ['case', ['get', 'highlight'], 4.5, 2.5],
|
||||||
|
'line-opacity': ['case', ['get', 'dim'], 0.15, 1]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// invisible wide line so thin routes are easy to click
|
||||||
|
map.addLayer({
|
||||||
|
id: 'tracks-hit',
|
||||||
|
type: 'line',
|
||||||
|
source: 'tracks',
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: { 'line-color': '#000', 'line-width': 16, 'line-opacity': 0.001 }
|
||||||
});
|
});
|
||||||
|
|
||||||
// peaks as native layers - scales to thousands of points
|
// peaks as native layers - scales to thousands of points
|
||||||
@@ -148,14 +356,9 @@
|
|||||||
source: 'peaks',
|
source: 'peaks',
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
|
'circle-radius': ['interpolate', ['linear'], ['zoom'], 5, 5, 12, 9],
|
||||||
'circle-color': [
|
'circle-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#1a1a19' : '#fcfcfb'],
|
||||||
'case',
|
|
||||||
['get', 'climbed'],
|
|
||||||
'#0ca30c',
|
|
||||||
dark ? '#1a1a19' : '#fcfcfb'
|
|
||||||
],
|
|
||||||
'circle-stroke-width': 1.5,
|
'circle-stroke-width': 1.5,
|
||||||
'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', dark ? '#898781' : '#898781']
|
'circle-stroke-color': ['case', ['get', 'climbed'], '#0ca30c', '#898781']
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
map.addLayer({
|
map.addLayer({
|
||||||
@@ -173,6 +376,72 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// live preview of a segment being created (accent, on top of tracks)
|
||||||
|
map.addSource('selection', { type: 'geojson', data: selectionGeojson() });
|
||||||
|
map.addLayer({
|
||||||
|
id: 'selection-line',
|
||||||
|
type: 'line',
|
||||||
|
source: 'selection',
|
||||||
|
layout: { 'line-join': 'round', 'line-cap': 'round' },
|
||||||
|
paint: {
|
||||||
|
'line-color': dark ? '#e66767' : '#e34948',
|
||||||
|
'line-width': 5,
|
||||||
|
'line-opacity': 0.95
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// start / finish markers
|
||||||
|
map.addSource('endpoints', { type: 'geojson', data: endpointsGeojson() });
|
||||||
|
map.addLayer({
|
||||||
|
id: 'endpoints-circles',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'endpoints',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 9,
|
||||||
|
'circle-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'kind'], 'start'],
|
||||||
|
'#0ca30c',
|
||||||
|
dark ? '#eef2f0' : '#0b0b0b'
|
||||||
|
],
|
||||||
|
'circle-stroke-width': 2,
|
||||||
|
'circle-stroke-color': dark ? '#1a1a19' : '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addLayer({
|
||||||
|
id: 'endpoints-glyphs',
|
||||||
|
type: 'symbol',
|
||||||
|
source: 'endpoints',
|
||||||
|
layout: {
|
||||||
|
'text-field': ['case', ['==', ['get', 'kind'], 'start'], 'S', 'F'],
|
||||||
|
'text-size': 10,
|
||||||
|
'text-allow-overlap': true,
|
||||||
|
'text-ignore-placement': true
|
||||||
|
},
|
||||||
|
paint: {
|
||||||
|
'text-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'kind'], 'start'],
|
||||||
|
'#ffffff',
|
||||||
|
dark ? '#0b0b0b' : '#ffffff'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// chart-hover position marker, matching the chart's crosshair dot
|
||||||
|
map.addSource('hover-point', { type: 'geojson', data: hoverGeojson() });
|
||||||
|
map.addLayer({
|
||||||
|
id: 'hover-point',
|
||||||
|
type: 'circle',
|
||||||
|
source: 'hover-point',
|
||||||
|
paint: {
|
||||||
|
'circle-radius': 7,
|
||||||
|
'circle-color': ['get', 'color'],
|
||||||
|
'circle-stroke-width': 2.5,
|
||||||
|
'circle-stroke-color': dark ? '#1a1a19' : '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// restore 3D terrain across style swaps
|
// restore 3D terrain across style swaps
|
||||||
if (pendingTerrain) {
|
if (pendingTerrain) {
|
||||||
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
map.setTerrain(pendingTerrain as import('maplibre-gl').TerrainSpecification);
|
||||||
@@ -185,7 +454,6 @@
|
|||||||
(async () => {
|
(async () => {
|
||||||
const maplibre = await import('maplibre-gl');
|
const maplibre = await import('maplibre-gl');
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
lib = maplibre;
|
|
||||||
|
|
||||||
currentDark = theme.isDark;
|
currentDark = theme.isDark;
|
||||||
map = new maplibre.Map({
|
map = new maplibre.Map({
|
||||||
@@ -203,6 +471,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
map.on('style.load', () => addLayers(currentDark ?? false));
|
map.on('style.load', () => addLayers(currentDark ?? false));
|
||||||
|
map.once('load', () => {
|
||||||
|
loaded = true;
|
||||||
|
lastFocusKey = JSON.stringify(focus);
|
||||||
|
applyCamera(false);
|
||||||
|
report();
|
||||||
|
});
|
||||||
|
|
||||||
// peak interaction: click to toggle, hover for tooltip
|
// peak interaction: click to toggle, hover for tooltip
|
||||||
const popup = new maplibre.Popup({
|
const popup = new maplibre.Popup({
|
||||||
@@ -231,28 +505,66 @@
|
|||||||
popup.remove();
|
popup.remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onviewport) {
|
// track interaction: click a route for an info pane
|
||||||
const report = () => {
|
const trackPane = new maplibre.Popup({
|
||||||
if (!map) return;
|
closeButton: true,
|
||||||
const b = map.getBounds();
|
closeOnClick: true,
|
||||||
onviewport({
|
offset: 10,
|
||||||
minLat: b.getSouth(),
|
maxWidth: '280px',
|
||||||
minLon: b.getWest(),
|
className: 'track-pane'
|
||||||
maxLat: b.getNorth(),
|
});
|
||||||
maxLon: b.getEast(),
|
map.on('click', 'tracks-hit', (e) => {
|
||||||
zoom: map.getZoom()
|
if (!map) return;
|
||||||
});
|
// a click on a peak wins over the route underneath
|
||||||
};
|
if (map.queryRenderedFeatures(e.point, { layers: ['peaks-circles'] }).length > 0) return;
|
||||||
map.on('moveend', report);
|
const feature = e.features?.[0];
|
||||||
map.once('load', report);
|
if (!feature) return;
|
||||||
}
|
const p = feature.properties;
|
||||||
|
|
||||||
const bounds = new maplibre.LngLatBounds();
|
const pane = document.createElement('div');
|
||||||
for (const track of tracks) for (const [lat, lon] of track.latlngs) bounds.extend([lon, lat]);
|
const title = document.createElement('strong');
|
||||||
for (const p of peaks) bounds.extend([p.lon, p.lat]);
|
title.textContent = p.name || 'Activity';
|
||||||
if (!bounds.isEmpty()) {
|
pane.appendChild(title);
|
||||||
map.fitBounds(bounds, { padding: 40, maxZoom: 14, animate: false });
|
const meta = document.createElement('div');
|
||||||
}
|
meta.className = 'tp-meta';
|
||||||
|
meta.textContent = [
|
||||||
|
p.username || null,
|
||||||
|
p.date ? fmtDate(p.date) : null,
|
||||||
|
p.distance_m ? fmtDistance(p.distance_m) : null,
|
||||||
|
p.type || null
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
pane.appendChild(meta);
|
||||||
|
if (p.id) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = `/activities/${p.id}`;
|
||||||
|
link.textContent = 'Open activity →';
|
||||||
|
link.className = 'tp-link';
|
||||||
|
pane.appendChild(link);
|
||||||
|
}
|
||||||
|
trackPane.setLngLat(e.lngLat).setDOMContent(pane).addTo(map);
|
||||||
|
});
|
||||||
|
map.on('mouseenter', 'tracks-hit', () => {
|
||||||
|
if (map) map.getCanvas().style.cursor = 'pointer';
|
||||||
|
});
|
||||||
|
map.on('mouseleave', 'tracks-hit', () => {
|
||||||
|
if (map) map.getCanvas().style.cursor = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = () => {
|
||||||
|
if (!map) return;
|
||||||
|
const b = map.getBounds();
|
||||||
|
onviewport?.({
|
||||||
|
minLat: b.getSouth(),
|
||||||
|
minLon: b.getWest(),
|
||||||
|
maxLat: b.getNorth(),
|
||||||
|
maxLon: b.getEast(),
|
||||||
|
zoom: map.getZoom()
|
||||||
|
});
|
||||||
|
};
|
||||||
|
reportFn = report;
|
||||||
|
map.on('moveend', report);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -276,9 +588,6 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
:global(.maplibregl-ctrl-group:not(:empty)) {
|
|
||||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
|
||||||
}
|
|
||||||
:global(.maplibregl-ctrl-group button + button) {
|
:global(.maplibregl-ctrl-group button + button) {
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -288,12 +597,16 @@
|
|||||||
:global(.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon) {
|
:global(.maplibregl-ctrl button.maplibregl-ctrl-terrain-enabled .maplibregl-ctrl-icon) {
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
:global(.maplibregl-ctrl-attrib) {
|
:global(.maplibregl-ctrl-attrib),
|
||||||
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
|
:global(.maplibregl-ctrl-attrib.maplibregl-compact) {
|
||||||
|
background: color-mix(in srgb, var(--surface-1) 85%, transparent) !important;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
:global(.maplibregl-ctrl-attrib a) {
|
:global(.maplibregl-ctrl-attrib a) {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary) !important;
|
||||||
|
}
|
||||||
|
:global(.maplibregl-ctrl-attrib-button) {
|
||||||
|
filter: var(--map-icon-filter);
|
||||||
}
|
}
|
||||||
:global(.maplibregl-popup.peak-tip .maplibregl-popup-content) {
|
:global(.maplibregl-popup.peak-tip .maplibregl-popup-content) {
|
||||||
background: var(--surface-1);
|
background: var(--surface-1);
|
||||||
@@ -309,4 +622,37 @@
|
|||||||
border-top-color: var(--surface-1);
|
border-top-color: var(--surface-1);
|
||||||
border-bottom-color: var(--surface-1);
|
border-bottom-color: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
:global(.maplibregl-popup.track-pane .maplibregl-popup-content) {
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.7rem 0.9rem;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
:global(.maplibregl-popup.track-pane .maplibregl-popup-tip) {
|
||||||
|
border-top-color: var(--surface-1);
|
||||||
|
border-bottom-color: var(--surface-1);
|
||||||
|
}
|
||||||
|
:global(.maplibregl-popup.track-pane .maplibregl-popup-close-button) {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
}
|
||||||
|
:global(.track-pane .tp-meta) {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin: 0.15rem 0 0.4rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
:global(.track-pane .tp-link) {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
:global(.track-pane .tp-link:hover) {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
|
||||||
|
// Marker component: pages render this to request the persistent map,
|
||||||
|
// which the layout shows in its fixed spot below the topbar - the same
|
||||||
|
// position on every page, so navigation never moves it.
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setWanted(true);
|
||||||
|
return () => mapState.setWanted(false);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,7 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
let { timed }: { timed: { d: number; t: number }[] } = $props();
|
let {
|
||||||
|
timed,
|
||||||
|
hoverD = null,
|
||||||
|
onhover,
|
||||||
|
onselect
|
||||||
|
}: {
|
||||||
|
timed: { d: number; t: number }[];
|
||||||
|
/** shared hover distance controlled by the page, so charts stay in sync */
|
||||||
|
hoverD?: number | null;
|
||||||
|
onhover?: (d: number | null) => void;
|
||||||
|
onselect?: (d: number) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
const WINDOWS = [
|
const WINDOWS = [
|
||||||
{ label: '10 s', w: 10 },
|
{ label: '10 s', w: 10 },
|
||||||
@@ -74,20 +85,23 @@
|
|||||||
: ''
|
: ''
|
||||||
);
|
);
|
||||||
|
|
||||||
let hover = $state<{ d: number; v: number } | null>(null);
|
// snap the shared hover distance to this chart's nearest point
|
||||||
|
const hover = $derived.by(() => {
|
||||||
function onmove(event: PointerEvent) {
|
if (hoverD === null || series.length === 0) return null;
|
||||||
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
|
||||||
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
|
||||||
const target = frac * totalDist;
|
|
||||||
let lo = 0;
|
let lo = 0;
|
||||||
let hi = series.length - 1;
|
let hi = series.length - 1;
|
||||||
while (hi - lo > 1) {
|
while (hi - lo > 1) {
|
||||||
const mid = (lo + hi) >> 1;
|
const mid = (lo + hi) >> 1;
|
||||||
if (series[mid].d < target) lo = mid;
|
if (series[mid].d < hoverD) lo = mid;
|
||||||
else hi = mid;
|
else hi = mid;
|
||||||
}
|
}
|
||||||
hover = target - series[lo].d < series[hi].d - target ? series[lo] : series[hi];
|
return hoverD - series[lo].d < series[hi].d - hoverD ? series[lo] : series[hi];
|
||||||
|
});
|
||||||
|
|
||||||
|
function onmove(event: PointerEvent) {
|
||||||
|
const rect = (event.currentTarget as SVGRectElement).getBoundingClientRect();
|
||||||
|
const frac = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
|
||||||
|
onhover?.(frac * totalDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
const tooltipLeft = $derived(hover ? Math.min(Math.max(x(hover.d), 70), width - 70) : 0);
|
||||||
@@ -134,7 +148,8 @@
|
|||||||
height={height - pad.top - pad.bottom}
|
height={height - pad.top - pad.bottom}
|
||||||
fill="transparent"
|
fill="transparent"
|
||||||
onpointermove={onmove}
|
onpointermove={onmove}
|
||||||
onpointerleave={() => (hover = null)}
|
onclick={() => hover && onselect?.(hover.d)}
|
||||||
|
onpointerleave={() => onhover?.(null)}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
{#if hover}
|
{#if hover}
|
||||||
|
|||||||
@@ -29,3 +29,13 @@ export function fmtDate(iso: string | null | undefined): string {
|
|||||||
export function fmtSpeed(mPerS: number): string {
|
export function fmtSpeed(mPerS: number): string {
|
||||||
return `${(mPerS * 3.6).toFixed(1)} km/h`;
|
return `${(mPerS * 3.6).toFixed(1)} km/h`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** mm:ss (or h:mm:ss) precision for segment efforts */
|
||||||
|
export function fmtElapsed(s: number): string {
|
||||||
|
const total = Math.round(s);
|
||||||
|
const h = Math.floor(total / 3600);
|
||||||
|
const m = Math.floor((total % 3600) / 60);
|
||||||
|
const sec = total % 60;
|
||||||
|
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
|
||||||
|
return `${m}:${String(sec).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
// Shared state for the single persistent map instance hosted by the layout.
|
||||||
|
// Pages configure what the map shows; the map itself never remounts, so
|
||||||
|
// navigation animates changes instead of rebuilding the canvas.
|
||||||
|
|
||||||
|
import type { MapPeak, MapTrack, Viewport } from '$lib/components/Map.svelte';
|
||||||
|
|
||||||
|
type Bounds = [[number, number], [number, number]];
|
||||||
|
|
||||||
|
let peaks = $state<MapPeak[]>([]);
|
||||||
|
let highlightId = $state<number | null>(null);
|
||||||
|
let focus = $state<Bounds | null>(null);
|
||||||
|
let detailTrack = $state<MapTrack | null>(null);
|
||||||
|
let hoverPoint = $state<{ lat: number; lon: number } | null>(null);
|
||||||
|
let selection = $state<[number, number][] | null>(null);
|
||||||
|
let carrier = $state<HTMLElement | null>(null);
|
||||||
|
let active = $state(false);
|
||||||
|
let wanted = $state(false);
|
||||||
|
|
||||||
|
let home: HTMLElement | null = null;
|
||||||
|
let peakClickHandler: ((id: number) => void) | null = null;
|
||||||
|
let viewportHandler: ((view: Viewport) => void) | null = null;
|
||||||
|
let flyToFn: ((lat: number, lon: number, zoom?: number) => void) | null = null;
|
||||||
|
let reportFn: (() => void) | null = null;
|
||||||
|
|
||||||
|
export const mapState = {
|
||||||
|
get peaks() {
|
||||||
|
return peaks;
|
||||||
|
},
|
||||||
|
get highlightId() {
|
||||||
|
return highlightId;
|
||||||
|
},
|
||||||
|
get focus() {
|
||||||
|
return focus;
|
||||||
|
},
|
||||||
|
get detailTrack() {
|
||||||
|
return detailTrack;
|
||||||
|
},
|
||||||
|
get hoverPoint() {
|
||||||
|
return hoverPoint;
|
||||||
|
},
|
||||||
|
get selection() {
|
||||||
|
return selection;
|
||||||
|
},
|
||||||
|
setSelection(latlngs: [number, number][] | null) {
|
||||||
|
selection = latlngs;
|
||||||
|
},
|
||||||
|
get carrier() {
|
||||||
|
return carrier;
|
||||||
|
},
|
||||||
|
get active() {
|
||||||
|
return active;
|
||||||
|
},
|
||||||
|
get wanted() {
|
||||||
|
return wanted;
|
||||||
|
},
|
||||||
|
setWanted(value: boolean) {
|
||||||
|
wanted = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPeaks(value: MapPeak[]) {
|
||||||
|
peaks = value;
|
||||||
|
},
|
||||||
|
setHighlight(id: number | null) {
|
||||||
|
highlightId = id;
|
||||||
|
},
|
||||||
|
setFocus(bounds: Bounds | null) {
|
||||||
|
focus = bounds;
|
||||||
|
},
|
||||||
|
setDetailTrack(track: MapTrack | null) {
|
||||||
|
detailTrack = track;
|
||||||
|
},
|
||||||
|
setHoverPoint(point: { lat: number; lon: number } | null) {
|
||||||
|
hoverPoint = point;
|
||||||
|
},
|
||||||
|
setHandlers(handlers: {
|
||||||
|
onpeakclick?: (id: number) => void;
|
||||||
|
onviewport?: (view: Viewport) => void;
|
||||||
|
}) {
|
||||||
|
peakClickHandler = handlers.onpeakclick ?? null;
|
||||||
|
viewportHandler = handlers.onviewport ?? null;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
peaks = [];
|
||||||
|
highlightId = null;
|
||||||
|
focus = null;
|
||||||
|
detailTrack = null;
|
||||||
|
hoverPoint = null;
|
||||||
|
selection = null;
|
||||||
|
peakClickHandler = null;
|
||||||
|
viewportHandler = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
handlePeakClick(id: number) {
|
||||||
|
peakClickHandler?.(id);
|
||||||
|
},
|
||||||
|
handleViewport(view: Viewport) {
|
||||||
|
viewportHandler?.(view);
|
||||||
|
},
|
||||||
|
|
||||||
|
registerCarrier(el: HTMLElement, homeEl: HTMLElement) {
|
||||||
|
carrier = el;
|
||||||
|
home = homeEl;
|
||||||
|
},
|
||||||
|
registerFlyTo(fn: (lat: number, lon: number, zoom?: number) => void) {
|
||||||
|
flyToFn = fn;
|
||||||
|
},
|
||||||
|
flyTo(lat: number, lon: number, zoom?: number) {
|
||||||
|
flyToFn?.(lat, lon, zoom);
|
||||||
|
},
|
||||||
|
registerReport(fn: () => void) {
|
||||||
|
reportFn = fn;
|
||||||
|
},
|
||||||
|
/** Ask the map to announce its viewport to the current handler. */
|
||||||
|
requestViewport() {
|
||||||
|
reportFn?.();
|
||||||
|
},
|
||||||
|
|
||||||
|
attach(slot: HTMLElement) {
|
||||||
|
if (carrier) {
|
||||||
|
slot.appendChild(carrier);
|
||||||
|
active = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
detach() {
|
||||||
|
if (carrier && home) {
|
||||||
|
home.appendChild(carrier);
|
||||||
|
active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import svgCaptcha from 'svg-captcha';
|
||||||
|
|
||||||
|
// In-memory challenge store - fine for a single-process deployment.
|
||||||
|
const pending = new Map<string, { answer: string; expires: number }>();
|
||||||
|
const TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [token, entry] of pending) {
|
||||||
|
if (entry.expires < now) pending.delete(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCaptcha(): { token: string; svg: string } {
|
||||||
|
cleanup();
|
||||||
|
const captcha = svgCaptcha.create({
|
||||||
|
size: 5,
|
||||||
|
noise: 3,
|
||||||
|
ignoreChars: '0Oo1ilIJ',
|
||||||
|
color: false
|
||||||
|
});
|
||||||
|
const token = randomBytes(16).toString('hex');
|
||||||
|
pending.set(token, { answer: captcha.text.toLowerCase(), expires: Date.now() + TTL_MS });
|
||||||
|
return { token, svg: captcha.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyCaptcha(token: string, answer: string): boolean {
|
||||||
|
// deliberate escape hatch for scripted/dev signups
|
||||||
|
const bypass = process.env.STREBA_CAPTCHA_BYPASS;
|
||||||
|
if (bypass && answer === bypass) return true;
|
||||||
|
|
||||||
|
const entry = pending.get(token);
|
||||||
|
pending.delete(token); // single use, right or wrong
|
||||||
|
return !!entry && entry.expires > Date.now() && entry.answer === answer.trim().toLowerCase();
|
||||||
|
}
|
||||||
+211
-2
@@ -3,6 +3,12 @@ import fs from 'node:fs';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { PEAKS } from './peaks-seed';
|
import { PEAKS } from './peaks-seed';
|
||||||
import { assignMinzoom, scorePeak } from './peak-score';
|
import { assignMinzoom, scorePeak } from './peak-score';
|
||||||
|
import { LIFT_ASSISTED_TYPES } from '$lib/activity-rules';
|
||||||
|
|
||||||
|
// SQL fragment: elevation gain that counts toward ascent totals
|
||||||
|
// (lift-assisted downhill types contribute 0)
|
||||||
|
const LIFT_LIST = LIFT_ASSISTED_TYPES.map((t) => `'${t}'`).join(', ');
|
||||||
|
export const COUNTED_GAIN = `CASE WHEN lower(trim(type)) IN (${LIFT_LIST}) THEN 0 ELSE elev_gain_m END`;
|
||||||
|
|
||||||
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
const DATA_DIR = process.env.STREBA_DATA_DIR ?? 'data';
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
@@ -60,6 +66,28 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
|
CREATE INDEX IF NOT EXISTS idx_peaks_lat_zoom ON peaks(minzoom, lat, lon);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS segments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
creator_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
points TEXT NOT NULL,
|
||||||
|
bounds TEXT NOT NULL,
|
||||||
|
distance_m REAL NOT NULL,
|
||||||
|
elev_gain_m REAL NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS segment_efforts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
segment_id INTEGER NOT NULL REFERENCES segments(id) ON DELETE CASCADE,
|
||||||
|
activity_id INTEGER NOT NULL REFERENCES activities(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
elapsed_s REAL,
|
||||||
|
date TEXT,
|
||||||
|
UNIQUE (segment_id, activity_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_efforts_segment ON segment_efforts(segment_id);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ascents (
|
CREATE TABLE IF NOT EXISTS ascents (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -234,7 +262,7 @@ export function userStats(userId: number): {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT count(*) activities,
|
`SELECT count(*) activities,
|
||||||
coalesce(sum(distance_m), 0) distance_m,
|
coalesce(sum(distance_m), 0) distance_m,
|
||||||
coalesce(sum(elev_gain_m), 0) elev_gain_m,
|
coalesce(sum(${COUNTED_GAIN}), 0) elev_gain_m,
|
||||||
coalesce(sum(coalesce(moving_s, duration_s, 0)), 0) moving_s
|
coalesce(sum(coalesce(moving_s, duration_s, 0)), 0) moving_s
|
||||||
FROM activities WHERE user_id = ?`
|
FROM activities WHERE user_id = ?`
|
||||||
)
|
)
|
||||||
@@ -252,7 +280,7 @@ export function communityTotals(): {
|
|||||||
`SELECT (SELECT count(*) FROM users) users,
|
`SELECT (SELECT count(*) FROM users) users,
|
||||||
count(*) activities,
|
count(*) activities,
|
||||||
coalesce(sum(distance_m), 0) distance_m,
|
coalesce(sum(distance_m), 0) distance_m,
|
||||||
coalesce(sum(elev_gain_m), 0) elev_gain_m
|
coalesce(sum(${COUNTED_GAIN}), 0) elev_gain_m
|
||||||
FROM activities`
|
FROM activities`
|
||||||
)
|
)
|
||||||
.get() as { users: number; activities: number; distance_m: number; elev_gain_m: number };
|
.get() as { users: number; activities: number; distance_m: number; elev_gain_m: number };
|
||||||
@@ -360,6 +388,187 @@ export function recordAscent(
|
|||||||
return result.changes > 0;
|
return result.changes > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SegmentRow {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
creator_id: number | null;
|
||||||
|
points: string;
|
||||||
|
bounds: string;
|
||||||
|
distance_m: number;
|
||||||
|
elev_gain_m: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listSegments(): (Omit<SegmentRow, 'points'> & {
|
||||||
|
efforts: number;
|
||||||
|
athletes: number;
|
||||||
|
best_s: number | null;
|
||||||
|
})[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id, s.name, s.creator_id, s.bounds, s.distance_m, s.elev_gain_m, s.created_at,
|
||||||
|
count(e.id) efforts,
|
||||||
|
count(DISTINCT e.user_id) athletes,
|
||||||
|
min(e.elapsed_s) best_s
|
||||||
|
FROM segments s LEFT JOIN segment_efforts e ON e.segment_id = s.id
|
||||||
|
GROUP BY s.id ORDER BY efforts DESC, s.id DESC`
|
||||||
|
)
|
||||||
|
.all() as (Omit<SegmentRow, 'points'> & {
|
||||||
|
efforts: number;
|
||||||
|
athletes: number;
|
||||||
|
best_s: number | null;
|
||||||
|
})[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSegment(id: number): SegmentRow | undefined {
|
||||||
|
return db.prepare('SELECT * FROM segments WHERE id = ?').get(id) as SegmentRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-user bests split by activity type - a run doesn't compete with a ride. */
|
||||||
|
export function segmentLeaderboard(segmentId: number): {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
type: string;
|
||||||
|
best_s: number;
|
||||||
|
attempts: number;
|
||||||
|
date: string | null;
|
||||||
|
}[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT e.user_id, u.username, a.type, min(e.elapsed_s) best_s, count(*) attempts,
|
||||||
|
(SELECT b.date FROM segment_efforts b
|
||||||
|
JOIN activities ab ON ab.id = b.activity_id
|
||||||
|
WHERE b.segment_id = e.segment_id AND b.user_id = e.user_id
|
||||||
|
AND ab.type = a.type AND b.elapsed_s IS NOT NULL
|
||||||
|
ORDER BY b.elapsed_s ASC LIMIT 1) date
|
||||||
|
FROM segment_efforts e
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
JOIN activities a ON a.id = e.activity_id
|
||||||
|
WHERE e.segment_id = ? AND e.elapsed_s IS NOT NULL
|
||||||
|
GROUP BY e.user_id, a.type ORDER BY best_s ASC`
|
||||||
|
)
|
||||||
|
.all(segmentId) as {
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
type: string;
|
||||||
|
best_s: number;
|
||||||
|
attempts: number;
|
||||||
|
date: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* All segments a user has completed, with their best time, attempts, and
|
||||||
|
* rank. Rank is computed within the activity type of the user's best effort,
|
||||||
|
* since leaderboards are split by type.
|
||||||
|
*/
|
||||||
|
export function userSegments(userId: number): {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
distance_m: number;
|
||||||
|
elev_gain_m: number;
|
||||||
|
attempts: number;
|
||||||
|
best_s: number | null;
|
||||||
|
best_type: string | null;
|
||||||
|
rank: number | null;
|
||||||
|
}[] {
|
||||||
|
const base = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT s.id, s.name, s.distance_m, s.elev_gain_m, count(e.id) attempts
|
||||||
|
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;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
const bestStmt = db.prepare(
|
||||||
|
`SELECT e.elapsed_s, a.type FROM segment_efforts e
|
||||||
|
JOIN activities a ON a.id = e.activity_id
|
||||||
|
WHERE e.segment_id = ? AND e.user_id = ? AND e.elapsed_s IS NOT NULL
|
||||||
|
ORDER BY e.elapsed_s ASC LIMIT 1`
|
||||||
|
);
|
||||||
|
const rankStmt = db.prepare(
|
||||||
|
`SELECT count(*) + 1 n FROM (
|
||||||
|
SELECT min(e2.elapsed_s) b FROM segment_efforts e2
|
||||||
|
JOIN activities a2 ON a2.id = e2.activity_id
|
||||||
|
WHERE e2.segment_id = ? AND a2.type = ? AND e2.elapsed_s IS NOT NULL AND e2.user_id != ?
|
||||||
|
GROUP BY e2.user_id
|
||||||
|
) others WHERE others.b < ?`
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.map((segment) => {
|
||||||
|
const best = bestStmt.get(segment.id, userId) as
|
||||||
|
| { elapsed_s: number; type: string }
|
||||||
|
| undefined;
|
||||||
|
const rank = best
|
||||||
|
? (rankStmt.get(segment.id, best.type, userId, best.elapsed_s) as { n: number }).n
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
...segment,
|
||||||
|
best_s: best?.elapsed_s ?? null,
|
||||||
|
best_type: best?.type ?? null,
|
||||||
|
rank
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every effort on a segment, fastest first (untimed last, by date). */
|
||||||
|
export function segmentAllEfforts(segmentId: number): {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
activity_id: number;
|
||||||
|
activity_name: string;
|
||||||
|
type: string;
|
||||||
|
elapsed_s: number | null;
|
||||||
|
date: string | null;
|
||||||
|
}[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT e.id, e.user_id, u.username, e.activity_id, a.name activity_name, a.type,
|
||||||
|
e.elapsed_s, e.date
|
||||||
|
FROM segment_efforts e
|
||||||
|
JOIN users u ON u.id = e.user_id
|
||||||
|
JOIN activities a ON a.id = e.activity_id
|
||||||
|
WHERE e.segment_id = ?
|
||||||
|
ORDER BY e.elapsed_s IS NULL, e.elapsed_s ASC, e.date DESC`
|
||||||
|
)
|
||||||
|
.all(segmentId) as {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
username: string;
|
||||||
|
activity_id: number;
|
||||||
|
activity_name: string;
|
||||||
|
type: string;
|
||||||
|
elapsed_s: number | null;
|
||||||
|
date: string | null;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentEffortsForActivity(activityId: number): { segment_id: number; name: string; elapsed_s: number | null }[] {
|
||||||
|
return db
|
||||||
|
.prepare(
|
||||||
|
`SELECT e.segment_id, s.name, e.elapsed_s
|
||||||
|
FROM segment_efforts e JOIN segments s ON s.id = e.segment_id
|
||||||
|
WHERE e.activity_id = ? ORDER BY s.name`
|
||||||
|
)
|
||||||
|
.all(activityId) as { segment_id: number; name: string; elapsed_s: number | null }[];
|
||||||
|
}
|
||||||
|
|
||||||
export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
export function reachedPeaks(activityId: number, userId: number): (PeakRow & { climbed_at: string | null })[] {
|
||||||
return db
|
return db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export interface ParsedGpx {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
type: string | null;
|
type: string | null;
|
||||||
date: string | null;
|
date: string | null;
|
||||||
|
/** ISO timestamp of the first trackpoint, when the GPX has times */
|
||||||
|
start: string | null;
|
||||||
points: TrackPoint[];
|
points: TrackPoint[];
|
||||||
distance_m: number;
|
distance_m: number;
|
||||||
duration_s: number | null;
|
duration_s: number | null;
|
||||||
@@ -149,6 +151,7 @@ export function parseGpx(xml: string): ParsedGpx {
|
|||||||
name: trk0?.name ? String(trk0.name) : meta?.name ? String(meta.name) : null,
|
name: trk0?.name ? String(trk0.name) : meta?.name ? String(meta.name) : null,
|
||||||
type: trk0?.type ? String(trk0.type).toLowerCase() : null,
|
type: trk0?.type ? String(trk0.type).toLowerCase() : null,
|
||||||
date,
|
date,
|
||||||
|
start: firstTime !== null ? new Date(firstTime).toISOString() : null,
|
||||||
points: simplify(points, 2500),
|
points: simplify(points, 2500),
|
||||||
distance_m: distance,
|
distance_m: distance,
|
||||||
duration_s: duration,
|
duration_s: duration,
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { db, getSegment, type SegmentRow } from './db';
|
||||||
|
import { haversine } from './gpx';
|
||||||
|
|
||||||
|
export interface TrackPt {
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
|
ele: number | null;
|
||||||
|
t: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORRIDOR_M = 45; // how far an activity may stray from the segment line
|
||||||
|
const MIN_SEGMENT_M = 100;
|
||||||
|
const SAMPLE_M = 30; // segment checkpoints spacing for matching
|
||||||
|
|
||||||
|
function cumDist(points: { lat: number; lon: number }[]): number[] {
|
||||||
|
const d: number[] = [0];
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
d.push(d[i - 1] + haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon));
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** thin a segment line to ~SAMPLE_M checkpoints, keeping first and last */
|
||||||
|
function checkpoints<T extends { lat: number; lon: number }>(points: T[]): T[] {
|
||||||
|
const d = cumDist(points);
|
||||||
|
const out: T[] = [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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try to find the segment inside an activity: the activity must pass the
|
||||||
|
* 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(
|
||||||
|
segPoints: { lat: number; lon: number }[],
|
||||||
|
actPoints: TrackPt[]
|
||||||
|
): { 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)
|
||||||
|
const candidates: number[] = [];
|
||||||
|
for (let i = 0; i < actPoints.length; i++) {
|
||||||
|
if (haversine(actPoints[i].lat, actPoints[i].lon, start.lat, start.lon) <= CORRIDOR_M) {
|
||||||
|
if (candidates.length === 0 || i - candidates[candidates.length - 1] > 15) candidates.push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matched = false;
|
||||||
|
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++) {
|
||||||
|
// 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 (found >= 0) {
|
||||||
|
j = found;
|
||||||
|
} else if (k === cps.length - 1 || ++misses > maxMiss) {
|
||||||
|
ok = false; // the finish itself may never be missed
|
||||||
|
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 segmentBounds(points: { lat: number; lon: number }[]): string {
|
||||||
|
let minLat = Infinity,
|
||||||
|
minLon = Infinity,
|
||||||
|
maxLat = -Infinity,
|
||||||
|
maxLon = -Infinity;
|
||||||
|
for (const p of points) {
|
||||||
|
minLat = Math.min(minLat, p.lat);
|
||||||
|
maxLat = Math.max(maxLat, p.lat);
|
||||||
|
minLon = Math.min(minLon, p.lon);
|
||||||
|
maxLon = Math.max(maxLon, p.lon);
|
||||||
|
}
|
||||||
|
return JSON.stringify([
|
||||||
|
[minLat, minLon],
|
||||||
|
[maxLat, maxLon]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gainOf(points: TrackPt[]): number {
|
||||||
|
let gain = 0;
|
||||||
|
let ref: number | null = 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 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`
|
||||||
|
);
|
||||||
|
|
||||||
|
function activityRows(): { id: number; user_id: number; date: string | null; points: string; bounds: string }[] {
|
||||||
|
return db
|
||||||
|
.prepare('SELECT id, user_id, date, points, bounds FROM activities')
|
||||||
|
.all() as { id: number; user_id: number; date: string | null; points: string; bounds: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundsOverlap(a: string, b: string, marginDeg = 0.002): boolean {
|
||||||
|
const [[aMinLat, aMinLon], [aMaxLat, aMaxLon]] = JSON.parse(a);
|
||||||
|
const [[bMinLat, bMinLon], [bMaxLat, bMaxLon]] = JSON.parse(b);
|
||||||
|
return (
|
||||||
|
aMinLat - marginDeg <= bMaxLat &&
|
||||||
|
aMaxLat + marginDeg >= bMinLat &&
|
||||||
|
aMinLon - marginDeg <= bMaxLon &&
|
||||||
|
aMaxLon + marginDeg >= bMinLon
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create a segment from a distance slice of an activity and match everyone against it. */
|
||||||
|
export function createSegmentFromSlice(
|
||||||
|
name: string,
|
||||||
|
creatorId: number,
|
||||||
|
activityId: number,
|
||||||
|
startD: number,
|
||||||
|
endD: number
|
||||||
|
): { id: number } | { error: string } {
|
||||||
|
const activity = db
|
||||||
|
.prepare('SELECT points FROM activities WHERE id = ?')
|
||||||
|
.get(activityId) as { points: string } | undefined;
|
||||||
|
if (!activity) return { error: 'Activity not found.' };
|
||||||
|
|
||||||
|
const points = JSON.parse(activity.points) as TrackPt[];
|
||||||
|
const d = cumDist(points);
|
||||||
|
const from = Math.min(startD, endD);
|
||||||
|
const to = Math.max(startD, endD);
|
||||||
|
const slice = points.filter((_, i) => d[i] >= from && d[i] <= to);
|
||||||
|
if (slice.length < 2 || to - from < MIN_SEGMENT_M) {
|
||||||
|
return { error: `A segment must be at least ${MIN_SEGMENT_M} m long.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO segments (name, creator_id, points, bounds, distance_m, elev_gain_m)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
name,
|
||||||
|
creatorId,
|
||||||
|
JSON.stringify(slice.map((p) => ({ lat: p.lat, lon: p.lon, ele: p.ele, t: null }))),
|
||||||
|
segmentBounds(slice),
|
||||||
|
to - from,
|
||||||
|
gainOf(slice)
|
||||||
|
);
|
||||||
|
const segmentId = Number(result.lastInsertRowid);
|
||||||
|
matchSegmentToAllActivities(segmentId);
|
||||||
|
return { id: segmentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan every activity for efforts on one (new) segment. */
|
||||||
|
export function matchSegmentToAllActivities(segmentId: number): number {
|
||||||
|
const segment = getSegment(segmentId);
|
||||||
|
if (!segment) return 0;
|
||||||
|
const segPoints = JSON.parse(segment.points) as TrackPt[];
|
||||||
|
let found = 0;
|
||||||
|
for (const activity of activityRows()) {
|
||||||
|
if (!boundsOverlap(segment.bounds, activity.bounds)) continue;
|
||||||
|
const effort = computeEffort(segPoints, JSON.parse(activity.points) as TrackPt[]);
|
||||||
|
if (effort) {
|
||||||
|
insertEffort.run(segmentId, activity.id, activity.user_id, effort.elapsed_s, activity.date);
|
||||||
|
found++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match one (new) activity against every segment. */
|
||||||
|
export function matchActivityToSegments(activityId: number): string[] {
|
||||||
|
const activity = db
|
||||||
|
.prepare('SELECT id, user_id, date, points, bounds FROM activities WHERE id = ?')
|
||||||
|
.get(activityId) as
|
||||||
|
| { id: number; user_id: number; date: string | null; points: string; bounds: string }
|
||||||
|
| undefined;
|
||||||
|
if (!activity) return [];
|
||||||
|
const actPoints = JSON.parse(activity.points) as TrackPt[];
|
||||||
|
const matched: string[] = [];
|
||||||
|
for (const segment of db.prepare('SELECT * FROM segments').all() as SegmentRow[]) {
|
||||||
|
if (!boundsOverlap(segment.bounds, activity.bounds)) continue;
|
||||||
|
const effort = computeEffort(JSON.parse(segment.points) as TrackPt[], actPoints);
|
||||||
|
if (effort) {
|
||||||
|
insertEffort.run(segment.id, activity.id, activity.user_id, effort.elapsed_s, activity.date);
|
||||||
|
matched.push(segment.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- suggestion mining: popular overlapping stretches --------------------
|
||||||
|
|
||||||
|
const CELL_DEG = 0.00028; // ~30 m grid
|
||||||
|
const MIN_ACTIVITIES = 5; // "more than 5x" -> stretches shared by > 5 activities
|
||||||
|
const MIN_STRETCH_M = 400;
|
||||||
|
|
||||||
|
function cellKey(lat: number, lon: number): string {
|
||||||
|
return `${Math.round(lat / CELL_DEG)}:${Math.round(lon / CELL_DEG)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentSuggestion {
|
||||||
|
activity_id: number;
|
||||||
|
activity_name: string;
|
||||||
|
start_d: number;
|
||||||
|
end_d: number;
|
||||||
|
distance_m: number;
|
||||||
|
activities: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find stretches travelled by more than MIN_ACTIVITIES distinct activities
|
||||||
|
* that aren't already covered by an existing segment.
|
||||||
|
*/
|
||||||
|
export function suggestSegments(limit = 8): SegmentSuggestion[] {
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT a.id, a.name, a.points FROM activities a')
|
||||||
|
.all() as { id: number; name: string; points: string }[];
|
||||||
|
if (rows.length <= MIN_ACTIVITIES) return [];
|
||||||
|
|
||||||
|
// pass 1: how many distinct activities touch each grid cell
|
||||||
|
const cellCounts = new Map<string, number>();
|
||||||
|
const parsed = rows.map((row) => {
|
||||||
|
const points = JSON.parse(row.points) as TrackPt[];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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 };
|
||||||
|
});
|
||||||
|
|
||||||
|
// cells already claimed by existing segments
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
for (const segment of db.prepare('SELECT points FROM segments').all() as { points: string }[]) {
|
||||||
|
for (const p of JSON.parse(segment.points) as TrackPt[]) claimed.add(cellKey(p.lat, p.lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
// pass 2: walk each activity, collect popular unclaimed runs
|
||||||
|
interface Candidate extends SegmentSuggestion {
|
||||||
|
cells: Set<string>;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
const candidates: Candidate[] = [];
|
||||||
|
for (const activity of parsed) {
|
||||||
|
const d = cumDist(activity.points);
|
||||||
|
let runStart = -1;
|
||||||
|
let gap = 0;
|
||||||
|
let counts: number[] = [];
|
||||||
|
const flush = (endIdx: number) => {
|
||||||
|
if (runStart < 0) return;
|
||||||
|
const length = d[endIdx] - d[runStart];
|
||||||
|
if (length >= MIN_STRETCH_M) {
|
||||||
|
const cells = new Set<string>();
|
||||||
|
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_id: activity.id,
|
||||||
|
activity_name: activity.name,
|
||||||
|
start_d: d[runStart],
|
||||||
|
end_d: d[endIdx],
|
||||||
|
distance_m: length,
|
||||||
|
activities: Math.round(avg),
|
||||||
|
cells,
|
||||||
|
score: length * avg
|
||||||
|
});
|
||||||
|
}
|
||||||
|
runStart = -1;
|
||||||
|
counts = [];
|
||||||
|
};
|
||||||
|
for (let i = 0; i < activity.points.length; i++) {
|
||||||
|
const key = cellKey(activity.points[i].lat, activity.points[i].lon);
|
||||||
|
const count = cellCounts.get(key) ?? 0;
|
||||||
|
const popular = count > MIN_ACTIVITIES && !claimed.has(key);
|
||||||
|
if (popular) {
|
||||||
|
if (runStart < 0) runStart = i;
|
||||||
|
gap = 0;
|
||||||
|
counts.push(count);
|
||||||
|
} else if (runStart >= 0 && ++gap > 4) {
|
||||||
|
flush(i - gap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(activity.points.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// greedy pick by score, skipping candidates that mostly repeat an accepted one
|
||||||
|
candidates.sort((a, b) => b.score - a.score);
|
||||||
|
const accepted: Candidate[] = [];
|
||||||
|
const used = new Set<string>();
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (accepted.length >= limit) break;
|
||||||
|
let overlap = 0;
|
||||||
|
for (const cell of candidate.cells) if (used.has(cell)) overlap++;
|
||||||
|
if (overlap / candidate.cells.size > 0.4) continue;
|
||||||
|
for (const cell of candidate.cells) used.add(cell);
|
||||||
|
accepted.push(candidate);
|
||||||
|
}
|
||||||
|
return accepted.map(({ cells: _cells, score: _score, ...suggestion }) => suggestion);
|
||||||
|
}
|
||||||
@@ -1,5 +1,38 @@
|
|||||||
|
import { db } from '$lib/server/db';
|
||||||
import type { LayoutServerLoad } from './$types';
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: LayoutServerLoad = ({ locals }) => {
|
export const load: LayoutServerLoad = ({ locals }) => {
|
||||||
return { user: locals.user };
|
// the overview: every user's tracks, thinned for the persistent map
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT a.id, a.name, a.type, a.date, a.distance_m, a.points, u.username
|
||||||
|
FROM activities a JOIN users u ON u.id = a.user_id`
|
||||||
|
)
|
||||||
|
.all() as {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
date: string | null;
|
||||||
|
distance_m: number;
|
||||||
|
points: string;
|
||||||
|
username: string;
|
||||||
|
}[];
|
||||||
|
const tracks = rows.map((row) => {
|
||||||
|
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
||||||
|
const step = Math.max(1, Math.floor(points.length / 300));
|
||||||
|
const latlngs = points
|
||||||
|
.filter((_, i) => i % step === 0 || i === points.length - 1)
|
||||||
|
.map((p) => [p.lat, p.lon] as [number, number]);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
username: row.username,
|
||||||
|
type: row.type,
|
||||||
|
date: row.date,
|
||||||
|
distance_m: row.distance_m,
|
||||||
|
latlngs
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { user: locals.user, tracks };
|
||||||
};
|
};
|
||||||
|
|||||||
+170
-4
@@ -2,7 +2,60 @@
|
|||||||
import '@fontsource-variable/inter';
|
import '@fontsource-variable/inter';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { onNavigate } from '$app/navigation';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
import Map from '$lib/components/Map.svelte';
|
||||||
|
|
||||||
|
let mapHome: HTMLDivElement;
|
||||||
|
let mapCarrier: HTMLDivElement;
|
||||||
|
let layoutSlot: HTMLDivElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (mapCarrier && mapHome) mapState.registerCarrier(mapCarrier, mapHome);
|
||||||
|
});
|
||||||
|
|
||||||
|
// show the persistent map in its fixed spot whenever the page asks for it
|
||||||
|
$effect(() => {
|
||||||
|
if (mapState.wanted && layoutSlot && mapState.carrier) {
|
||||||
|
mapState.attach(layoutSlot);
|
||||||
|
return () => mapState.detach();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mapComponent: Map | undefined = $state();
|
||||||
|
$effect(() => {
|
||||||
|
if (mapComponent) {
|
||||||
|
mapState.registerFlyTo((lat, lon, zoom) => mapComponent!.flyTo(lat, lon, zoom));
|
||||||
|
mapState.registerReport(() => mapComponent!.reportViewport());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// the current activity's full-resolution track replaces its thinned overview twin
|
||||||
|
const mapTracks = $derived.by(() => {
|
||||||
|
const detail = mapState.detailTrack;
|
||||||
|
if (!detail) return data.tracks;
|
||||||
|
let replaced = false;
|
||||||
|
const merged = data.tracks.map((t) => {
|
||||||
|
if (t.id === detail.id) {
|
||||||
|
replaced = true;
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
return replaced ? merged : [...merged, detail];
|
||||||
|
});
|
||||||
|
|
||||||
|
// cross-fade page content on navigation (the map morphs on its own)
|
||||||
|
onNavigate((navigation) => {
|
||||||
|
if (!document.startViewTransition) return;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
document.startViewTransition(async () => {
|
||||||
|
resolve();
|
||||||
|
await navigation.complete;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const themeTitle = $derived(
|
const themeTitle = $derived(
|
||||||
theme.pref === 'auto'
|
theme.pref === 'auto'
|
||||||
@@ -17,6 +70,7 @@
|
|||||||
const links = [
|
const links = [
|
||||||
{ href: '/', label: 'Dashboard' },
|
{ href: '/', label: 'Dashboard' },
|
||||||
{ href: '/activities', label: 'Activities' },
|
{ href: '/activities', label: 'Activities' },
|
||||||
|
{ href: '/segments', label: 'Segments' },
|
||||||
{ href: '/peaks', label: 'Peaks' },
|
{ href: '/peaks', label: 'Peaks' },
|
||||||
{ href: '/upload', label: 'Upload' }
|
{ href: '/upload', label: 'Upload' }
|
||||||
];
|
];
|
||||||
@@ -92,10 +146,55 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
<div
|
||||||
|
class="layout-map-slot"
|
||||||
|
bind:this={layoutSlot}
|
||||||
|
style:display={mapState.wanted ? 'block' : 'none'}
|
||||||
|
></div>
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer">Streba - the GPX analyser</footer>
|
<!-- the persistent map lives here when no page is showing it -->
|
||||||
|
<div class="map-home" bind:this={mapHome} aria-hidden={!mapState.active}>
|
||||||
|
<div
|
||||||
|
class="map-carrier"
|
||||||
|
bind:this={mapCarrier}
|
||||||
|
style:view-transition-name={mapState.active ? 'streba-map' : 'none'}
|
||||||
|
>
|
||||||
|
<Map
|
||||||
|
bind:this={mapComponent}
|
||||||
|
tracks={mapTracks}
|
||||||
|
peaks={mapState.peaks}
|
||||||
|
highlightId={mapState.highlightId}
|
||||||
|
focus={mapState.focus}
|
||||||
|
hoverPoint={mapState.hoverPoint}
|
||||||
|
selection={mapState.selection}
|
||||||
|
onpeakclick={(id) => mapState.handlePeakClick(id)}
|
||||||
|
onviewport={(view) => mapState.handleViewport(view)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="footer-inner">
|
||||||
|
<div class="footer-brand">
|
||||||
|
<span class="footer-name">Streba</span>
|
||||||
|
<span class="footer-tag">The GPX analyser</span>
|
||||||
|
</div>
|
||||||
|
{#if data.user}
|
||||||
|
<nav class="footer-links" aria-label="Footer">
|
||||||
|
{#each links as link (link.href)}
|
||||||
|
<a href={link.href}>{link.label}</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="footer-credits">
|
||||||
|
© {new Date().getFullYear()} Streba · Terrain
|
||||||
|
<a href="https://mapterhorn.com">© Mapterhorn</a> · Peak data
|
||||||
|
<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.topbar {
|
.topbar {
|
||||||
@@ -105,6 +204,7 @@
|
|||||||
background: color-mix(in srgb, var(--surface-1) 82%, transparent);
|
background: color-mix(in srgb, var(--surface-1) 82%, transparent);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
|
view-transition-name: topbar;
|
||||||
}
|
}
|
||||||
.topbar-inner {
|
.topbar-inner {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
@@ -221,15 +321,81 @@
|
|||||||
}
|
}
|
||||||
main {
|
main {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2rem 1.25rem 4rem;
|
padding: 2rem 1.25rem 4rem;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.footer {
|
.footer {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--surface-1);
|
||||||
|
view-transition-name: site-footer;
|
||||||
|
}
|
||||||
|
.footer-inner {
|
||||||
max-width: 1080px;
|
max-width: 1080px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1.5rem 1.25rem 2.5rem;
|
padding: 1.5rem 1.25rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.footer-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
.footer-name {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.footer-tag {
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: 0.8rem;
|
font-size: 0.85rem;
|
||||||
border-top: 1px solid var(--border);
|
}
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.footer-links a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.footer-links a:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.footer-credits {
|
||||||
|
max-width: 1080px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.75rem 1.25rem 1.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.footer-credits a {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
/* parking spot for the map while no page shows it: kept alive, out of sight */
|
||||||
|
.map-home {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 720px;
|
||||||
|
height: 440px;
|
||||||
|
/* visibility alone leaks maplibre children that set their own visibility */
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
.map-carrier {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.layout-map-slot {
|
||||||
|
height: min(440px, 52vh);
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,27 +1,10 @@
|
|||||||
import { communityTotals, countAscents, db, listAllActivities, listAscents } from '$lib/server/db';
|
import { communityTotals, countAscents, listAllActivities, listAscents } from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ locals }) => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
// every user's tracks, thinned for the overview map
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT a.id, a.name, a.points, u.username
|
|
||||||
FROM activities a JOIN users u ON u.id = a.user_id`
|
|
||||||
)
|
|
||||||
.all() as { id: number; name: string; points: string; username: string }[];
|
|
||||||
const tracks = rows.map((row) => {
|
|
||||||
const points = JSON.parse(row.points) as { lat: number; lon: number }[];
|
|
||||||
const step = Math.max(1, Math.floor(points.length / 300));
|
|
||||||
const latlngs = points
|
|
||||||
.filter((_, i) => i % step === 0 || i === points.length - 1)
|
|
||||||
.map((p) => [p.lat, p.lon] as [number, number]);
|
|
||||||
return { id: row.id, name: `${row.name} · ${row.username}`, latlngs };
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = locals.user;
|
const user = locals.user;
|
||||||
return {
|
return {
|
||||||
activities: listAllActivities(),
|
activities: listAllActivities(),
|
||||||
tracks,
|
|
||||||
totals: communityTotals(),
|
totals: communityTotals(),
|
||||||
mine: user
|
mine: user
|
||||||
? {
|
? {
|
||||||
|
|||||||
+6
-25
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Map from '$lib/components/Map.svelte';
|
import MapSlot from '$lib/components/MapSlot.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||||
import { fmtDistance, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtElevation } from '$lib/format';
|
||||||
@@ -16,16 +16,10 @@
|
|||||||
<p class="page-sub">What everyone on Streba has been up to.</p>
|
<p class="page-sub">What everyone on Streba has been up to.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="hero">
|
<div class="hero">
|
||||||
<div>
|
<h1>The GPX analyser</h1>
|
||||||
<h1>The GPX analyser</h1>
|
<p class="page-sub">
|
||||||
<p class="page-sub">
|
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
|
||||||
Upload your tracks, analyse every climb - and tick off Alpine peaks along the way.
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="hero-actions">
|
|
||||||
<a class="btn" href="/signup">Sign up</a>
|
|
||||||
<a class="btn ghost" href="/login">Sign in</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -36,8 +30,7 @@
|
|||||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Worldmap</h2>
|
<MapSlot />
|
||||||
<Map tracks={data.tracks} height="440px" />
|
|
||||||
|
|
||||||
<h2>Recent activities</h2>
|
<h2>Recent activities</h2>
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
@@ -66,18 +59,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hero {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1.5rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.hero-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.6rem;
|
|
||||||
padding-top: 0.35rem;
|
|
||||||
}
|
|
||||||
.kpis {
|
.kpis {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
import { listActivities } from '$lib/server/db';
|
import { COUNTED_GAIN, db, listActivities } from '$lib/server/db';
|
||||||
|
import { countsTowardAscent } from '$lib/activity-rules';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ locals }) => {
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
const activities = listActivities(locals.user!.id);
|
const activities = listActivities(locals.user!.id);
|
||||||
|
|
||||||
|
const years = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT substr(coalesce(date, substr(created_at, 1, 10)), 1, 4) year,
|
||||||
|
count(*) count,
|
||||||
|
sum(distance_m) distance_m,
|
||||||
|
sum(${COUNTED_GAIN}) elev_gain_m,
|
||||||
|
sum(coalesce(moving_s, duration_s, 0)) moving_s
|
||||||
|
FROM activities WHERE user_id = ?
|
||||||
|
GROUP BY year ORDER BY year DESC`
|
||||||
|
)
|
||||||
|
.all(locals.user!.id) as {
|
||||||
|
year: string;
|
||||||
|
count: number;
|
||||||
|
distance_m: number;
|
||||||
|
elev_gain_m: number;
|
||||||
|
moving_s: number;
|
||||||
|
}[];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activities,
|
activities,
|
||||||
|
years,
|
||||||
totals: {
|
totals: {
|
||||||
count: activities.length,
|
count: activities.length,
|
||||||
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
|
distance_m: activities.reduce((sum, a) => sum + a.distance_m, 0),
|
||||||
elev_gain_m: activities.reduce((sum, a) => sum + a.elev_gain_m, 0),
|
elev_gain_m: activities.reduce(
|
||||||
|
(sum, a) => sum + (countsTowardAscent(a.type) ? a.elev_gain_m : 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
moving_s: activities.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
|
import { countsTowardAscent } from '$lib/activity-rules';
|
||||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
let selectedYear = $state<string | null>(null);
|
||||||
|
|
||||||
|
function activityYear(a: { date: string | null; created_at: string }): string {
|
||||||
|
return (a.date ?? a.created_at).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = $derived(
|
||||||
|
selectedYear === null
|
||||||
|
? data.activities
|
||||||
|
: data.activities.filter((a) => activityYear(a) === selectedYear)
|
||||||
|
);
|
||||||
|
const shown = $derived({
|
||||||
|
count: filtered.length,
|
||||||
|
distance_m: filtered.reduce((sum, a) => sum + a.distance_m, 0),
|
||||||
|
elev_gain_m: filtered.reduce(
|
||||||
|
(sum, a) => sum + (countsTowardAscent(a.type) ? a.elev_gain_m : 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
moving_s: filtered.reduce((sum, a) => sum + (a.moving_s ?? a.duration_s ?? 0), 0)
|
||||||
|
});
|
||||||
|
const suffix = $derived(selectedYear === null ? '' : ` (${selectedYear})`);
|
||||||
|
|
||||||
|
function pickYear(year: string) {
|
||||||
|
selectedYear = selectedYear === year ? null : year;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -12,25 +39,65 @@
|
|||||||
|
|
||||||
<h1>Your activities</h1>
|
<h1>Your activities</h1>
|
||||||
<p class="page-sub">
|
<p class="page-sub">
|
||||||
{data.activities.length} recorded {data.activities.length === 1 ? 'activity' : 'activities'}.
|
{shown.count} recorded {shown.count === 1 ? 'activity' : 'activities'}{suffix}.
|
||||||
|
{#if selectedYear}
|
||||||
|
<button class="clear-year" onclick={() => (selectedYear = null)}>Show all years</button>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{#if data.activities.length > 0}
|
{#if data.activities.length > 0}
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<StatTile label="Total distance" value={fmtDistance(data.totals.distance_m)} />
|
<StatTile label="Distance{suffix}" value={fmtDistance(shown.distance_m)} />
|
||||||
<StatTile label="Total ascent" value={fmtElevation(data.totals.elev_gain_m)} />
|
<StatTile label="Ascent{suffix}" value={fmtElevation(shown.elev_gain_m)} />
|
||||||
<StatTile label="Moving time" value={fmtDuration(data.totals.moving_s)} />
|
<StatTile label="Moving time{suffix}" value={fmtDuration(shown.moving_s)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if data.years.length > 1}
|
||||||
|
<h2>Per year</h2>
|
||||||
|
<div class="card year-table-wrap">
|
||||||
|
<table class="year-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Year</th>
|
||||||
|
<th>Activities</th>
|
||||||
|
<th>Distance</th>
|
||||||
|
<th>Ascent</th>
|
||||||
|
<th>Moving time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.years as y (y.year)}
|
||||||
|
<tr
|
||||||
|
class="year-row"
|
||||||
|
class:on={selectedYear === y.year}
|
||||||
|
onclick={() => pickYear(y.year)}
|
||||||
|
title={selectedYear === y.year ? 'Show all years' : `Show only ${y.year}`}
|
||||||
|
>
|
||||||
|
<td>{y.year}</td>
|
||||||
|
<td>{y.count}</td>
|
||||||
|
<td>{fmtDistance(y.distance_m)}</td>
|
||||||
|
<td>{fmtElevation(y.elev_gain_m)} ↑</td>
|
||||||
|
<td>{fmtDuration(y.moving_s)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.activities.length === 0}
|
{#if filtered.length === 0 && data.activities.length > 0}
|
||||||
|
<div class="card empty">
|
||||||
|
<p>No activities in {selectedYear}.</p>
|
||||||
|
</div>
|
||||||
|
{:else if data.activities.length === 0}
|
||||||
<div class="card empty">
|
<div class="card empty">
|
||||||
<p>No activities yet.</p>
|
<p>No activities yet.</p>
|
||||||
<a class="btn" href="/upload">Upload a GPX file</a>
|
<a class="btn" href="/upload">Upload a GPX file</a>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="card list">
|
<div class="card list">
|
||||||
{#each data.activities as activity (activity.id)}
|
{#each filtered as activity (activity.id)}
|
||||||
<ActivityItem {activity} />
|
<ActivityItem {activity} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -43,6 +110,56 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
.year-table-wrap {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.year-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.year-table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.6rem 1rem 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.year-table td {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.year-table tbody tr:not(:last-child) td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.year-table td:first-child {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.year-row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.year-row:hover td {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.year-row.on td {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
}
|
||||||
|
.year-row.on td:first-child {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
}
|
||||||
|
.clear-year {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
.list :global(.item:not(:last-child)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { error, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { deleteActivity, getActivity, reachedPeaks } from '$lib/server/db';
|
import { db, deleteActivity, getActivity, reachedPeaks, segmentEffortsForActivity } from '$lib/server/db';
|
||||||
|
import { createSegmentFromSlice } from '$lib/server/segments';
|
||||||
import { haversine } from '$lib/server/gpx';
|
import { haversine } from '$lib/server/gpx';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
@@ -16,14 +17,17 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
|
|
||||||
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
|
const latlngs = points.map((p) => [p.lat, p.lon] as [number, number]);
|
||||||
|
|
||||||
// cumulative-distance profiles for the elevation and speed charts
|
// cumulative-distance profiles for the elevation and speed charts,
|
||||||
|
// plus per-point distance so chart hover can find the map position
|
||||||
const profile: { d: number; ele: number }[] = [];
|
const profile: { d: number; ele: number }[] = [];
|
||||||
const timed: { d: number; t: number }[] = [];
|
const timed: { d: number; t: number }[] = [];
|
||||||
|
const trackD: number[] = [];
|
||||||
let dist = 0;
|
let dist = 0;
|
||||||
for (let i = 0; i < points.length; i++) {
|
for (let i = 0; i < points.length; i++) {
|
||||||
if (i > 0) {
|
if (i > 0) {
|
||||||
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
|
dist += haversine(points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon);
|
||||||
}
|
}
|
||||||
|
trackD.push(dist);
|
||||||
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
|
if (points[i].ele !== null) profile.push({ d: dist, ele: points[i].ele! });
|
||||||
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
|
if (points[i].t !== null) timed.push({ d: dist, t: points[i].t! });
|
||||||
}
|
}
|
||||||
@@ -33,7 +37,10 @@ export const load: PageServerLoad = ({ params, locals }) => {
|
|||||||
return {
|
return {
|
||||||
activity: { ...activity, points: undefined },
|
activity: { ...activity, points: undefined },
|
||||||
canDelete: locals.user?.id === activity.user_id,
|
canDelete: locals.user?.id === activity.user_id,
|
||||||
|
canCreateSegment: !!locals.user,
|
||||||
|
segmentEfforts: segmentEffortsForActivity(activity.id),
|
||||||
latlngs,
|
latlngs,
|
||||||
|
trackD,
|
||||||
profile,
|
profile,
|
||||||
timed: timed.length > 2 ? timed : [],
|
timed: timed.length > 2 ? timed : [],
|
||||||
bagged: bagged.map((p) => ({
|
bagged: bagged.map((p) => ({
|
||||||
@@ -52,5 +59,37 @@ export const actions: Actions = {
|
|||||||
if (!locals.user) error(401, 'Not signed in');
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
deleteActivity(Number(params.id), locals.user.id);
|
deleteActivity(Number(params.id), locals.user.id);
|
||||||
redirect(303, '/activities');
|
redirect(303, '/activities');
|
||||||
|
},
|
||||||
|
segment: async ({ params, locals, request }) => {
|
||||||
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim().slice(0, 80);
|
||||||
|
const startD = Number(form.get('start_d'));
|
||||||
|
const endD = Number(form.get('end_d'));
|
||||||
|
if (!name) return fail(400, { error: 'Give the segment a name.' });
|
||||||
|
const result = createSegmentFromSlice(
|
||||||
|
name,
|
||||||
|
locals.user.id,
|
||||||
|
Number(params.id),
|
||||||
|
startD,
|
||||||
|
endD
|
||||||
|
);
|
||||||
|
if ('error' in result) return fail(400, { error: result.error });
|
||||||
|
redirect(303, `/segments/${result.id}`);
|
||||||
|
},
|
||||||
|
update: async ({ params, locals, request }) => {
|
||||||
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim().slice(0, 120);
|
||||||
|
const type = String(form.get('type') ?? '').trim().toLowerCase().slice(0, 40);
|
||||||
|
if (!name) return fail(400, { error: 'Name cannot be empty.' });
|
||||||
|
if (!type) return fail(400, { error: 'Type cannot be empty.' });
|
||||||
|
db.prepare('UPDATE activities SET name = ?, type = ? WHERE id = ? AND user_id = ?').run(
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
Number(params.id),
|
||||||
|
locals.user.id
|
||||||
|
);
|
||||||
|
return { updated: true };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,127 +1,481 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from "$app/forms";
|
||||||
import Map from '$lib/components/Map.svelte';
|
import MapSlot from "$lib/components/MapSlot.svelte";
|
||||||
import ElevationChart from '$lib/components/ElevationChart.svelte';
|
import ElevationChart from "$lib/components/ElevationChart.svelte";
|
||||||
import SpeedChart from '$lib/components/SpeedChart.svelte';
|
import SpeedChart from "$lib/components/SpeedChart.svelte";
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from "$lib/components/StatTile.svelte";
|
||||||
import { fmtDate, fmtDistance, fmtDuration, fmtElevation, fmtSpeed } from '$lib/format';
|
import { mapState } from "$lib/map-state.svelte";
|
||||||
|
import {
|
||||||
|
fmtDate,
|
||||||
|
fmtDistance,
|
||||||
|
fmtDuration,
|
||||||
|
fmtElapsed,
|
||||||
|
fmtElevation,
|
||||||
|
fmtSpeed,
|
||||||
|
} from "$lib/format";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data, form } = $props();
|
||||||
const a = $derived(data.activity);
|
const a = $derived(data.activity);
|
||||||
|
|
||||||
const avgSpeed = $derived(
|
const avgSpeed = $derived(
|
||||||
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null
|
a.moving_s && a.moving_s > 0 ? fmtSpeed(a.distance_m / a.moving_s) : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let editing = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
// create-segment panel: a distance slice of this track
|
||||||
|
let segmenting = $state(false);
|
||||||
|
let segStart = $state(0);
|
||||||
|
let segEnd = $state(0);
|
||||||
|
$effect(() => {
|
||||||
|
segEnd = Math.round(a.distance_m);
|
||||||
|
});
|
||||||
|
|
||||||
|
// live preview of the selected slice on the map
|
||||||
|
$effect(() => {
|
||||||
|
if (!segmenting) {
|
||||||
|
mapState.setSelection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const from = Math.min(segStart, segEnd);
|
||||||
|
const to = Math.max(segStart, segEnd);
|
||||||
|
const slice: [number, number][] = [];
|
||||||
|
for (let i = 0; i < data.trackD.length; i++) {
|
||||||
|
if (data.trackD[i] >= from && data.trackD[i] <= to)
|
||||||
|
slice.push(data.latlngs[i]);
|
||||||
|
}
|
||||||
|
mapState.setSelection(slice);
|
||||||
|
return () => mapState.setSelection(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// chart hover -> marker on the map at the matching track position
|
||||||
|
function pointAt(d: number): [number, number] {
|
||||||
|
const trackD = data.trackD;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = trackD.length - 1;
|
||||||
|
while (hi - lo > 1) {
|
||||||
|
const mid = (lo + hi) >> 1;
|
||||||
|
if (trackD[mid] < d) lo = mid;
|
||||||
|
else hi = mid;
|
||||||
|
}
|
||||||
|
return data.latlngs[d - trackD[lo] < trackD[hi] - d ? lo : hi];
|
||||||
|
}
|
||||||
|
|
||||||
|
let hoverD = $state<number | null>(null);
|
||||||
|
|
||||||
|
function chartHover(d: number | null) {
|
||||||
|
hoverD = d;
|
||||||
|
if (d === null) {
|
||||||
|
mapState.setHoverPoint(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const [lat, lon] = pointAt(d);
|
||||||
|
mapState.setHoverPoint({ lat, lon });
|
||||||
|
}
|
||||||
|
|
||||||
|
// chart click -> fly the map to that spot
|
||||||
|
function chartSelect(d: number) {
|
||||||
|
const [lat, lon] = pointAt(d);
|
||||||
|
mapState.flyTo(lat, lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
// the persistent map keeps the overview visible, dims the rest,
|
||||||
|
// and animates toward this activity
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setDetailTrack({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
username: a.username,
|
||||||
|
type: a.type,
|
||||||
|
date: a.date,
|
||||||
|
distance_m: a.distance_m,
|
||||||
|
latlngs: data.latlngs,
|
||||||
|
});
|
||||||
|
mapState.setHighlight(a.id);
|
||||||
|
mapState.setPeaks(data.bagged);
|
||||||
|
mapState.setFocus(JSON.parse(a.bounds));
|
||||||
|
return () => mapState.reset();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>{a.name} · Streba</title>
|
<title>{a.name} · Streba</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
<a class="back" href="/">← Overview</a>
|
||||||
|
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<div>
|
{#if editing}
|
||||||
<h1>{a.name}</h1>
|
<form
|
||||||
<p class="page-sub">
|
class="edit-form"
|
||||||
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(a.date)} ·
|
method="POST"
|
||||||
<span class="type">{a.type}</span>
|
action="?/update"
|
||||||
</p>
|
use:enhance={() => {
|
||||||
</div>
|
saving = true;
|
||||||
{#if data.canDelete}
|
return async ({ update, result }) => {
|
||||||
<form
|
saving = false;
|
||||||
method="POST"
|
if (result.type === "success") editing = false;
|
||||||
action="?/delete"
|
await update();
|
||||||
use:enhance={({ cancel }) => {
|
};
|
||||||
if (!confirm('Delete this activity? Peaks it reached stay in your list.')) cancel();
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<input
|
||||||
<button class="btn danger" type="submit">Delete</button>
|
class="edit-name"
|
||||||
</form>
|
name="name"
|
||||||
{/if}
|
required
|
||||||
|
maxlength="120"
|
||||||
|
value={a.name}
|
||||||
|
aria-label="Activity name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="edit-type"
|
||||||
|
name="type"
|
||||||
|
required
|
||||||
|
maxlength="40"
|
||||||
|
value={a.type}
|
||||||
|
list="activity-types"
|
||||||
|
aria-label="Activity type"
|
||||||
|
/>
|
||||||
|
<datalist id="activity-types">
|
||||||
|
{#each ["hike", "run", "ride", "ski", "climbing", "walk", "snowshoe"] as t (t)}
|
||||||
|
<option value={t}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
<button class="btn" type="submit" disabled={saving}
|
||||||
|
>{saving ? "Saving…" : "Save"}</button
|
||||||
|
>
|
||||||
|
<button class="btn ghost" type="button" onclick={() => (editing = false)}
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
{#if form?.error}<span class="edit-error">{form.error}</span>{/if}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<div>
|
||||||
|
<h1>
|
||||||
|
{a.name}
|
||||||
|
{#if data.canDelete}
|
||||||
|
<button
|
||||||
|
class="edit-btn"
|
||||||
|
title="Edit name and type"
|
||||||
|
aria-label="Edit name and type"
|
||||||
|
onclick={() => (editing = true)}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
<a class="who" href="/users/{a.username}">{a.username}</a> · {fmtDate(
|
||||||
|
a.date,
|
||||||
|
)} ·
|
||||||
|
<span class="type">{a.type}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if data.canDelete && !editing}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (
|
||||||
|
!confirm("Delete this activity? Peaks it reached stay in your list.")
|
||||||
|
)
|
||||||
|
cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="btn danger" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="kpis">
|
<div class="kpis">
|
||||||
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
<StatTile label="Distance" value={fmtDistance(a.distance_m)} />
|
||||||
<StatTile label="Ascent" value={fmtElevation(a.elev_gain_m)} detail="↓ {fmtElevation(a.elev_loss_m)}" />
|
<StatTile
|
||||||
<StatTile
|
label="Ascent"
|
||||||
label="Moving time"
|
value={fmtElevation(a.elev_gain_m)}
|
||||||
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
detail="↓ {fmtElevation(a.elev_loss_m)}"
|
||||||
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ''}
|
/>
|
||||||
/>
|
<StatTile
|
||||||
{#if avgSpeed}
|
label="Moving time"
|
||||||
<StatTile label="Avg moving speed" value={avgSpeed} />
|
value={fmtDuration(a.moving_s ?? a.duration_s)}
|
||||||
{:else}
|
detail={a.duration_s ? `total ${fmtDuration(a.duration_s)}` : ""}
|
||||||
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
/>
|
||||||
{/if}
|
{#if avgSpeed}
|
||||||
|
<StatTile label="Avg moving speed" value={avgSpeed} />
|
||||||
|
{:else}
|
||||||
|
<StatTile label="Highest point" value={fmtElevation(a.elev_max_m)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if data.bagged.length > 0}
|
{#if data.bagged.length > 0}
|
||||||
<div class="card bagged">
|
<div class="card bagged">
|
||||||
⛰ Peaks reached:
|
⛰ Peaks reached:
|
||||||
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ', ' : ' '}<strong>{peak.name}</strong> ({peak.elevation_m} m){/each}
|
{#each data.bagged as peak, i (peak.id)}{i > 0 ? ", " : " "}<strong
|
||||||
</div>
|
>{peak.name}</strong
|
||||||
|
>
|
||||||
|
({peak.elevation_m} m){/each}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<h2>Map</h2>
|
{#if data.segmentEfforts.length > 0}
|
||||||
<Map tracks={[{ id: a.id, name: a.name, latlngs: data.latlngs }]} peaks={data.bagged} height="440px" />
|
<div class="card segments">
|
||||||
|
<span class="seg-title">Segments on this activity:</span>
|
||||||
|
{#each data.segmentEfforts as effort, i (effort.segment_id)}{i > 0
|
||||||
|
? " · "
|
||||||
|
: " "}<a href="/segments/{effort.segment_id}">{effort.name}</a>{#if effort.elapsed_s !== null} ({fmtElapsed(
|
||||||
|
effort.elapsed_s,
|
||||||
|
)}){/if}{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.canCreateSegment}
|
||||||
|
<div class="segment-create">
|
||||||
|
{#if segmenting}
|
||||||
|
<form class="card seg-form" method="POST" action="?/segment" use:enhance>
|
||||||
|
<div class="seg-sliders">
|
||||||
|
<label>
|
||||||
|
Start · {fmtDistance(Math.min(segStart, segEnd))}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
name="start_d"
|
||||||
|
min="0"
|
||||||
|
max={Math.round(a.distance_m)}
|
||||||
|
step="10"
|
||||||
|
bind:value={segStart}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End · {fmtDistance(Math.max(segStart, segEnd))}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
name="end_d"
|
||||||
|
min="0"
|
||||||
|
max={Math.round(a.distance_m)}
|
||||||
|
step="10"
|
||||||
|
bind:value={segEnd}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span class="seg-len"
|
||||||
|
>Length: {fmtDistance(Math.abs(segEnd - segStart))}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="seg-actions">
|
||||||
|
<input name="name" placeholder="Segment name" required maxlength="80" />
|
||||||
|
<button class="btn" type="submit">Create segment</button>
|
||||||
|
<button class="btn ghost" type="button" onclick={() => (segmenting = false)}
|
||||||
|
>Cancel</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if form?.error}<p class="edit-error">{form.error}</p>{/if}
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button class="btn ghost" onclick={() => (segmenting = true)}
|
||||||
|
>+ Create segment from this activity</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<MapSlot />
|
||||||
|
|
||||||
{#if data.profile.length > 1}
|
{#if data.profile.length > 1}
|
||||||
<h2>Elevation profile</h2>
|
<h2>Elevation profile</h2>
|
||||||
<div class="card chart">
|
<div class="card chart">
|
||||||
<ElevationChart profile={data.profile} />
|
<ElevationChart
|
||||||
<div class="chart-meta">
|
profile={data.profile}
|
||||||
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
{hoverD}
|
||||||
<span>High {fmtElevation(a.elev_max_m)}</span>
|
onhover={chartHover}
|
||||||
</div>
|
onselect={chartSelect}
|
||||||
</div>
|
/>
|
||||||
|
<div class="chart-meta">
|
||||||
|
<span>Low {fmtElevation(a.elev_min_m)}</span>
|
||||||
|
<span>High {fmtElevation(a.elev_max_m)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if data.timed.length > 2}
|
{#if data.timed.length > 2}
|
||||||
<h2>Speed</h2>
|
<h2>Speed</h2>
|
||||||
<div class="card chart">
|
<div class="card chart">
|
||||||
<SpeedChart timed={data.timed} />
|
<SpeedChart
|
||||||
</div>
|
timed={data.timed}
|
||||||
|
{hoverD}
|
||||||
|
onhover={chartHover}
|
||||||
|
onselect={chartSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.head {
|
.back {
|
||||||
display: flex;
|
display: inline-block;
|
||||||
justify-content: space-between;
|
margin-bottom: 0.75rem;
|
||||||
align-items: flex-start;
|
font-size: 0.85rem;
|
||||||
gap: 1rem;
|
font-weight: 500;
|
||||||
}
|
color: var(--text-secondary);
|
||||||
.type {
|
text-decoration: none;
|
||||||
text-transform: capitalize;
|
padding: 0.25rem 0.6rem;
|
||||||
}
|
margin-left: -0.6rem;
|
||||||
.who {
|
border-radius: 0.45rem;
|
||||||
font-weight: 600;
|
}
|
||||||
color: var(--accent-strong);
|
.back:hover {
|
||||||
text-decoration: none;
|
background: var(--wash);
|
||||||
}
|
color: var(--text-primary);
|
||||||
.who:hover {
|
}
|
||||||
text-decoration: underline;
|
.head {
|
||||||
}
|
display: flex;
|
||||||
.kpis {
|
justify-content: space-between;
|
||||||
display: grid;
|
align-items: flex-start;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
gap: 1rem;
|
||||||
gap: 0.75rem;
|
}
|
||||||
}
|
.edit-btn {
|
||||||
.bagged {
|
border: none;
|
||||||
margin-top: 1rem;
|
background: none;
|
||||||
padding: 0.85rem 1.15rem;
|
font-size: 1rem;
|
||||||
border-left: 3px solid var(--good);
|
color: var(--text-muted);
|
||||||
color: var(--text-secondary);
|
cursor: pointer;
|
||||||
}
|
padding: 0.15rem 0.4rem;
|
||||||
.bagged strong {
|
border-radius: 0.4rem;
|
||||||
color: var(--text-primary);
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.chart {
|
.edit-btn:hover {
|
||||||
padding: 1rem 1rem 0.5rem;
|
background: var(--wash);
|
||||||
}
|
color: var(--text-primary);
|
||||||
.chart-meta {
|
}
|
||||||
display: flex;
|
.edit-form {
|
||||||
gap: 1.25rem;
|
display: flex;
|
||||||
padding: 0.5rem 0.25rem 0.6rem;
|
gap: 0.5rem;
|
||||||
font-size: 0.8rem;
|
align-items: center;
|
||||||
color: var(--text-muted);
|
flex-wrap: wrap;
|
||||||
}
|
width: 100%;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.edit-form input {
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.edit-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.edit-type {
|
||||||
|
width: 120px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.edit-form input:focus {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.edit-error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.type {
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.segments {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.seg-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.segments a {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.segments a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.segment-create {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.seg-form {
|
||||||
|
padding: 1.15rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
.seg-sliders {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.seg-sliders label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
.seg-sliders input[type="range"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.seg-len {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.seg-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.seg-actions input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
padding: 0.5rem 0.7rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
.who {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.who:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.bagged {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
border-left: 3px solid var(--good);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.bagged strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.chart {
|
||||||
|
padding: 1rem 1rem 0.5rem;
|
||||||
|
}
|
||||||
|
.chart-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
padding: 0.5rem 0.25rem 0.6rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Map, { type Viewport } from '$lib/components/Map.svelte';
|
import MapSlot from '$lib/components/MapSlot.svelte';
|
||||||
|
import type { Viewport } from '$lib/components/Map.svelte';
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -22,7 +24,6 @@
|
|||||||
let viewPeaks: ViewPeak[] = $state([]);
|
let viewPeaks: ViewPeak[] = $state([]);
|
||||||
let tab: 'view' | 'mine' = $state('view');
|
let tab: 'view' | 'mine' = $state('view');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let mapRef: Map | undefined = $state();
|
|
||||||
|
|
||||||
// search
|
// search
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -49,13 +50,36 @@
|
|||||||
function goTo(peak: ViewPeak) {
|
function goTo(peak: ViewPeak) {
|
||||||
searchOpen = false;
|
searchOpen = false;
|
||||||
query = peak.name;
|
query = peak.name;
|
||||||
mapRef?.flyTo(peak.lat, peak.lon, 12.5);
|
mapState.flyTo(peak.lat, peak.lon, 12.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
const LIST_CAP = 150;
|
const LIST_CAP = 150;
|
||||||
// the "In view" list only offers peaks still to climb; climbed ones live in "My ascents"
|
// the "In view" list only offers peaks still to climb; climbed ones live in "My ascents"
|
||||||
const toClimb = $derived(viewPeaks.filter((p) => !p.climbed_at));
|
const toClimb = $derived(viewPeaks.filter((p) => !p.climbed_at));
|
||||||
|
|
||||||
|
// map clicks confirm via a small dialog - easy to hit a peak while panning/zooming
|
||||||
|
let confirmPeak: ViewPeak | null = $state(null);
|
||||||
|
|
||||||
|
function onMapPeakClick(id: number) {
|
||||||
|
confirmPeak = viewPeaks.find((p) => p.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmToggle() {
|
||||||
|
if (!confirmPeak) return;
|
||||||
|
await toggle(confirmPeak.id);
|
||||||
|
confirmPeak = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// drive the persistent map: our peaks, our handlers, viewport-fed fetching
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setPeaks(mapPeaks);
|
||||||
|
});
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setHandlers({ onpeakclick: onMapPeakClick, onviewport });
|
||||||
|
mapState.requestViewport();
|
||||||
|
return () => mapState.reset();
|
||||||
|
});
|
||||||
|
|
||||||
const highest = $derived(ascents[0] ?? null);
|
const highest = $derived(ascents[0] ?? null);
|
||||||
|
|
||||||
const mapPeaks = $derived(
|
const mapPeaks = $derived(
|
||||||
@@ -134,6 +158,8 @@
|
|||||||
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
Zoom in to reveal less prominent summits; click a peak to cross it off.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<MapSlot />
|
||||||
|
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
@@ -159,7 +185,35 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Map bind:this={mapRef} peaks={mapPeaks} height="480px" onpeakclick={toggle} {onviewport} />
|
{#if confirmPeak}
|
||||||
|
<div
|
||||||
|
class="dialog-backdrop"
|
||||||
|
role="presentation"
|
||||||
|
onclick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) confirmPeak = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="dialog card" role="dialog" aria-modal="true" aria-label="Peak">
|
||||||
|
<p class="d-name">{confirmPeak.name}</p>
|
||||||
|
<p class="d-sub">
|
||||||
|
{confirmPeak.elevation_m
|
||||||
|
? `${Math.round(confirmPeak.elevation_m).toLocaleString('en-US')} m`
|
||||||
|
: 'elevation unknown'}
|
||||||
|
{#if confirmPeak.climbed_at}
|
||||||
|
· reached {fmtDate(confirmPeak.climbed_at)}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="d-actions">
|
||||||
|
{#if confirmPeak.climbed_at}
|
||||||
|
<button class="btn danger" onclick={confirmToggle}>Remove ascent</button>
|
||||||
|
{:else}
|
||||||
|
<button class="btn" onclick={confirmToggle}>Mark as reached</button>
|
||||||
|
{/if}
|
||||||
|
<button class="btn ghost" onclick={() => (confirmPeak = null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="tabs" role="group" aria-label="Peak lists">
|
<div class="tabs" role="group" aria-label="Peak lists">
|
||||||
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
|
<button class="filter-btn" class:on={tab === 'view'} onclick={() => (tab = 'view')}>
|
||||||
@@ -225,7 +279,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.search {
|
.search {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 0.75rem;
|
margin-top: 0.75rem;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
}
|
}
|
||||||
.search input {
|
.search input {
|
||||||
@@ -288,6 +342,35 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.dialog {
|
||||||
|
padding: 1.25rem 1.4rem;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
.d-name {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
.d-sub {
|
||||||
|
margin: 0.2rem 0 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
.d-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { listSegments } from '$lib/server/db';
|
||||||
|
import { createSegmentFromSlice, suggestSegments } from '$lib/server/segments';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ locals }) => {
|
||||||
|
return {
|
||||||
|
segments: listSegments(),
|
||||||
|
suggestions: locals.user ? suggestSegments() : []
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
// create a segment from a mined suggestion
|
||||||
|
create: async ({ request, locals }) => {
|
||||||
|
if (!locals.user) error(401, 'Not signed in');
|
||||||
|
const form = await request.formData();
|
||||||
|
const name = String(form.get('name') ?? '').trim().slice(0, 80);
|
||||||
|
const activityId = Number(form.get('activity_id'));
|
||||||
|
const startD = Number(form.get('start_d'));
|
||||||
|
const endD = Number(form.get('end_d'));
|
||||||
|
if (!name) return fail(400, { error: 'Give the segment a name.' });
|
||||||
|
if (!Number.isFinite(activityId) || !Number.isFinite(startD) || !Number.isFinite(endD)) {
|
||||||
|
return fail(400, { error: 'Invalid segment range.' });
|
||||||
|
}
|
||||||
|
const result = createSegmentFromSlice(name, locals.user.id, activityId, startD, endD);
|
||||||
|
if ('error' in result) return fail(400, { error: result.error });
|
||||||
|
redirect(303, `/segments/${result.id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fmtDistance, fmtElevation } from '$lib/format';
|
||||||
|
import { fmtElapsed } from '$lib/format';
|
||||||
|
|
||||||
|
let { data, form } = $props();
|
||||||
|
let naming = $state<number | null>(null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Segments · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>Segments</h1>
|
||||||
|
<p class="page-sub">
|
||||||
|
Community stretches everyone competes on - created from activities, timed automatically for
|
||||||
|
every upload.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.segments.length === 0}
|
||||||
|
<div class="card empty">
|
||||||
|
No segments yet. Create one from an activity page, or pick a suggested stretch below.
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card list">
|
||||||
|
{#each data.segments as segment (segment.id)}
|
||||||
|
<a class="segment" href="/segments/{segment.id}">
|
||||||
|
<span class="s-name">{segment.name}</span>
|
||||||
|
<span class="s-meta">
|
||||||
|
{fmtDistance(segment.distance_m)} · {fmtElevation(segment.elev_gain_m)} ↑ ·
|
||||||
|
{segment.athletes}
|
||||||
|
{segment.athletes === 1 ? 'athlete' : 'athletes'} · {segment.efforts}
|
||||||
|
{segment.efforts === 1 ? 'effort' : 'efforts'}
|
||||||
|
{#if segment.best_s !== null}
|
||||||
|
· record {fmtElapsed(segment.best_s)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if data.suggestions.length > 0}
|
||||||
|
<h2>Suggested segments</h2>
|
||||||
|
<p class="page-sub">
|
||||||
|
Stretches covered by more than 5 activities that aren't segments yet.
|
||||||
|
</p>
|
||||||
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
|
<div class="card list">
|
||||||
|
{#each data.suggestions as suggestion, i (i)}
|
||||||
|
<div class="suggestion">
|
||||||
|
<div class="sg-info">
|
||||||
|
<span class="s-name">
|
||||||
|
{fmtDistance(suggestion.distance_m)} stretch
|
||||||
|
<span class="sg-count">{suggestion.activities} activities</span>
|
||||||
|
</span>
|
||||||
|
<span class="s-meta">
|
||||||
|
from “{suggestion.activity_name}”, km {(suggestion.start_d / 1000).toFixed(1)}–{(
|
||||||
|
suggestion.end_d / 1000
|
||||||
|
).toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if naming === i}
|
||||||
|
<form class="sg-form" method="POST" action="?/create" use:enhance>
|
||||||
|
<input type="hidden" name="activity_id" value={suggestion.activity_id} />
|
||||||
|
<input type="hidden" name="start_d" value={suggestion.start_d} />
|
||||||
|
<input type="hidden" name="end_d" value={suggestion.end_d} />
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input name="name" placeholder="Segment name" required maxlength="80" autofocus />
|
||||||
|
<button class="btn" type="submit">Create</button>
|
||||||
|
<button class="btn ghost" type="button" onclick={() => (naming = null)}>Cancel</button>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button class="btn ghost" onclick={() => (naming = i)}>Make segment</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.list :global(> *:not(:last-child)),
|
||||||
|
.list > *:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.segment {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
.segment:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.s-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.s-meta {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.85rem 1.15rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.sg-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sg-count {
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
background: var(--accent-wash);
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.sg-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sg-form input[name='name'] {
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--surface-1);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: var(--critical);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { deleteSegment, getSegment, segmentAllEfforts, segmentLeaderboard } from '$lib/server/db';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
import type { TrackPt } from '$lib/server/segments';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = ({ params, locals }) => {
|
||||||
|
const segment = getSegment(Number(params.id));
|
||||||
|
if (!segment) error(404, 'Segment not found');
|
||||||
|
|
||||||
|
const points = JSON.parse(segment.points) as TrackPt[];
|
||||||
|
return {
|
||||||
|
segment: { ...segment, points: undefined },
|
||||||
|
latlngs: points.map((p) => [p.lat, p.lon] as [number, number]),
|
||||||
|
leaderboard: segmentLeaderboard(segment.id),
|
||||||
|
efforts: segmentAllEfforts(segment.id),
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import MapSlot from '$lib/components/MapSlot.svelte';
|
||||||
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
|
import { mapState } from '$lib/map-state.svelte';
|
||||||
|
import { typeSlot } from '$lib/activity-colors';
|
||||||
|
import { fmtDate, fmtDistance, fmtElapsed, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
const s = $derived(data.segment);
|
||||||
|
|
||||||
|
// efforts and leaderboards are split by activity type
|
||||||
|
const types = $derived.by(() => {
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
for (const effort of data.efforts) {
|
||||||
|
counts.set(effort.type, (counts.get(effort.type) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([type]) => type);
|
||||||
|
});
|
||||||
|
let picked = $state<string | null>(null);
|
||||||
|
const selectedType = $derived(picked ?? types[0] ?? null);
|
||||||
|
const board = $derived(data.leaderboard.filter((row) => row.type === selectedType));
|
||||||
|
const efforts = $derived(data.efforts.filter((row) => row.type === selectedType));
|
||||||
|
|
||||||
|
// show the segment on the persistent map: synthetic negative id so it can
|
||||||
|
// be highlighted without colliding with activity ids
|
||||||
|
$effect(() => {
|
||||||
|
mapState.setDetailTrack({
|
||||||
|
id: -s.id,
|
||||||
|
name: s.name,
|
||||||
|
type: 'segment',
|
||||||
|
latlngs: data.latlngs
|
||||||
|
});
|
||||||
|
mapState.setHighlight(-s.id);
|
||||||
|
mapState.setFocus(JSON.parse(s.bounds));
|
||||||
|
return () => mapState.reset();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{s.name} · Streba</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<MapSlot />
|
||||||
|
|
||||||
|
<a class="back" href="/segments">← Segments</a>
|
||||||
|
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1>{s.name}</h1>
|
||||||
|
<p class="page-sub">Segment · created {fmtDate(s.created_at.slice(0, 10))}</p>
|
||||||
|
</div>
|
||||||
|
{#if data.canDelete}
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/delete"
|
||||||
|
use:enhance={({ cancel }) => {
|
||||||
|
if (!confirm('Delete this segment and all its efforts?')) cancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="btn danger" type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="kpis">
|
||||||
|
<StatTile label="Distance" value={fmtDistance(s.distance_m)} />
|
||||||
|
<StatTile label="Ascent" value={fmtElevation(s.elev_gain_m)} />
|
||||||
|
<StatTile
|
||||||
|
label={selectedType ? `Record (${selectedType})` : 'Record'}
|
||||||
|
value={board[0] ? fmtElapsed(board[0].best_s) : '–'}
|
||||||
|
detail={board[0]?.username ?? 'no timed efforts yet'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if types.length > 1}
|
||||||
|
<div class="type-chips" role="group" aria-label="Activity type">
|
||||||
|
{#each types as type (type)}
|
||||||
|
<button
|
||||||
|
class="chip"
|
||||||
|
class:on={selectedType === type}
|
||||||
|
style="--tc: var(--cat-{typeSlot(type)})"
|
||||||
|
onclick={() => (picked = type)}
|
||||||
|
>
|
||||||
|
{type}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2>Leaderboard{selectedType ? ` · ${selectedType}` : ''}</h2>
|
||||||
|
{#if board.length === 0}
|
||||||
|
<div class="card empty">No timed efforts yet - ride or run it with a GPS track.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>#</th><th>Athlete</th><th>Best time</th><th>Attempts</th><th>Date</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each board as row, i (row.user_id + row.type)}
|
||||||
|
<tr class:me={row.user_id === data.myUserId}>
|
||||||
|
<td>{i + 1}</td>
|
||||||
|
<td><a href="/users/{row.username}">{row.username}</a></td>
|
||||||
|
<td class="time">{fmtElapsed(row.best_s)}</td>
|
||||||
|
<td>{row.attempts}</td>
|
||||||
|
<td>{row.date ? fmtDate(row.date) : '–'}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if efforts.length > 0}
|
||||||
|
<h2>All efforts{selectedType ? ` · ${selectedType}` : ''} ({efforts.length})</h2>
|
||||||
|
<div class="card table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Athlete</th><th>Time</th><th>Date</th><th>Activity</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each efforts as effort (effort.id)}
|
||||||
|
<tr class:me={effort.user_id === data.myUserId}>
|
||||||
|
<td><a href="/users/{effort.username}">{effort.username}</a></td>
|
||||||
|
<td class="time">{effort.elapsed_s !== null ? fmtElapsed(effort.elapsed_s) : '–'}</td>
|
||||||
|
<td>{effort.date ? fmtDate(effort.date) : '–'}</td>
|
||||||
|
<td><a href="/activities/{effort.activity_id}">{effort.activity_name}</a></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.back {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
margin-left: -0.6rem;
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
}
|
||||||
|
.back:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.kpis {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
.type-chips {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: capitalize;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.chip.on {
|
||||||
|
background: color-mix(in srgb, var(--tc) 14%, transparent);
|
||||||
|
border-color: transparent;
|
||||||
|
color: var(--tc);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 0.6rem 1rem 0.35rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
tbody tr:not(:last-child) td {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
tr.me td {
|
||||||
|
background: var(--accent-wash);
|
||||||
|
}
|
||||||
|
td a {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
padding: 1.75rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { fail, redirect } from '@sveltejs/kit';
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
import { createSession, createUser, findUser } from '$lib/server/auth';
|
import { createSession, createUser, findUser } from '$lib/server/auth';
|
||||||
import type { Actions } from './$types';
|
import { createCaptcha, verifyCaptcha } from '$lib/server/captcha';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = () => {
|
||||||
|
return { captcha: createCaptcha() };
|
||||||
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
default: async ({ request, cookies }) => {
|
default: async ({ request, cookies }) => {
|
||||||
@@ -8,6 +13,14 @@ export const actions: Actions = {
|
|||||||
const username = String(form.get('username') ?? '').trim();
|
const username = String(form.get('username') ?? '').trim();
|
||||||
const password = String(form.get('password') ?? '');
|
const password = String(form.get('password') ?? '');
|
||||||
|
|
||||||
|
// honeypot: real browsers leave this hidden field empty
|
||||||
|
if (String(form.get('website') ?? '') !== '') {
|
||||||
|
return fail(400, { username, error: 'Signup rejected.' });
|
||||||
|
}
|
||||||
|
if (!verifyCaptcha(String(form.get('token') ?? ''), String(form.get('captcha') ?? ''))) {
|
||||||
|
return fail(400, { username, error: 'The characters did not match - try the new image.' });
|
||||||
|
}
|
||||||
|
|
||||||
if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) {
|
if (!/^[a-zA-Z0-9_.-]{3,30}$/.test(username)) {
|
||||||
return fail(400, {
|
return fail(400, {
|
||||||
username,
|
username,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { form } = $props();
|
let { data, form } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -26,6 +26,20 @@
|
|||||||
Password
|
Password
|
||||||
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
|
<input name="password" type="password" required minlength="8" autocomplete="new-password" />
|
||||||
</label>
|
</label>
|
||||||
|
<!-- honeypot: hidden from people, tempting for bots -->
|
||||||
|
<label class="hp" aria-hidden="true">
|
||||||
|
Website
|
||||||
|
<input name="website" tabindex="-1" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<div class="captcha">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- server-generated SVG -->
|
||||||
|
{@html data.captcha.svg}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Type the characters above
|
||||||
|
<input name="captcha" required autocomplete="off" spellcheck="false" />
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="token" value={data.captcha.token} />
|
||||||
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
{#if form?.error}<p class="error">{form.error}</p>{/if}
|
||||||
<button class="btn" type="submit">Sign up</button>
|
<button class="btn" type="submit">Sign up</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -64,6 +78,23 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
border-color: transparent;
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
|
.hp {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
|
.captcha {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
.captcha :global(svg) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
color: var(--critical);
|
color: var(--critical);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
import { fail } from '@sveltejs/kit';
|
import { fail } from '@sveltejs/kit';
|
||||||
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
import { db, peaksNearBounds, recordAscent } from '$lib/server/db';
|
||||||
|
import { matchActivityToSegments } from '$lib/server/segments';
|
||||||
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
import { matchPeaks, parseGpx } from '$lib/server/gpx';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
/** A "name" that is really just a date or timestamp, e.g. "2023-10-03 17:39:35". */
|
||||||
|
const DATE_ONLY_NAME =
|
||||||
|
/^\d{4}[-/.]\d{1,2}[-/.]\d{1,2}([ T](\d{1,2}[:.]\d{2}([:.]\d{2})?|\d{4,6}))?Z?$/;
|
||||||
|
|
||||||
|
/** Infer an activity type from average moving speed when the GPX doesn't say. */
|
||||||
|
function inferType(gpx: { distance_m: number; moving_s: number | null }): string | null {
|
||||||
|
if (!gpx.moving_s || gpx.moving_s < 60) return null;
|
||||||
|
const kmh = (gpx.distance_m / gpx.moving_s) * 3.6;
|
||||||
|
if (kmh >= 13) return 'ride';
|
||||||
|
if (kmh >= 7) return 'run';
|
||||||
|
return 'hike';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Morning Hike", "Evening Run", or "Hike on 3 Oct 2023" when there's no start time. */
|
||||||
|
function generateName(type: string, start: string | null, date: string | null): string {
|
||||||
|
const capitalized = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
if (start) {
|
||||||
|
const hour = new Date(start).getUTCHours();
|
||||||
|
const period =
|
||||||
|
hour >= 4 && hour < 11
|
||||||
|
? 'Morning'
|
||||||
|
: hour >= 11 && hour < 14
|
||||||
|
? 'Lunch'
|
||||||
|
: hour >= 14 && hour < 18
|
||||||
|
? 'Afternoon'
|
||||||
|
: hour >= 18 && hour < 22
|
||||||
|
? 'Evening'
|
||||||
|
: 'Night';
|
||||||
|
return `${period} ${capitalized}`;
|
||||||
|
}
|
||||||
|
if (date) {
|
||||||
|
const nice = new Date(date + 'T12:00:00').toLocaleDateString('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
return `${capitalized} on ${nice}`;
|
||||||
|
}
|
||||||
|
return capitalized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse export-style filenames like
|
* Parse export-style filenames like
|
||||||
* "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name).
|
* "2018-01-11 141322 - Run - Afternoon Run.gpx" (Date - Type - Name).
|
||||||
@@ -29,7 +71,7 @@ export const actions: Actions = {
|
|||||||
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
const files = form.getAll('gpx').filter((f): f is File => f instanceof File && f.size > 0);
|
||||||
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
if (files.length === 0) return fail(400, { error: 'No GPX files selected.' });
|
||||||
|
|
||||||
const uploaded: { id: number; name: string; newPeaks: string[] }[] = [];
|
const uploaded: { id: number; name: string; newPeaks: string[]; segments: string[] }[] = [];
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
|
|
||||||
const insert = db.prepare(`
|
const insert = db.prepare(`
|
||||||
@@ -45,10 +87,22 @@ export const actions: Actions = {
|
|||||||
try {
|
try {
|
||||||
const gpx = parseGpx(await file.text());
|
const gpx = parseGpx(await file.text());
|
||||||
const parsed = parseFilename(file.name);
|
const parsed = parseFilename(file.name);
|
||||||
|
|
||||||
|
// a missing, "outdoor", or numeric (old Strava code) type gets inferred from pace
|
||||||
|
let type = parsed?.type ?? gpx.type ?? '';
|
||||||
|
if (!type || type === 'outdoor' || /^\d+$/.test(type)) {
|
||||||
|
type = inferType(gpx) ?? 'outdoor';
|
||||||
|
}
|
||||||
|
// a name that is just a date/timestamp gets a friendly generated one
|
||||||
|
let name = parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, '');
|
||||||
|
if (!name.trim() || DATE_ONLY_NAME.test(name.trim())) {
|
||||||
|
name = generateName(type, gpx.start, gpx.date ?? parsed?.date ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
const result = insert.run({
|
const result = insert.run({
|
||||||
user_id: userId,
|
user_id: userId,
|
||||||
name: parsed?.name ?? gpx.name ?? file.name.replace(/\.gpx$/i, ''),
|
name,
|
||||||
type: parsed?.type ?? gpx.type ?? 'outdoor',
|
type,
|
||||||
date: gpx.date ?? parsed?.date ?? null,
|
date: gpx.date ?? parsed?.date ?? null,
|
||||||
distance_m: gpx.distance_m,
|
distance_m: gpx.distance_m,
|
||||||
duration_s: gpx.duration_s,
|
duration_s: gpx.duration_s,
|
||||||
@@ -69,11 +123,8 @@ export const actions: Actions = {
|
|||||||
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
if (recordAscent(userId, peak.id, activityId, gpx.date)) newPeaks.push(peak.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
uploaded.push({
|
const segments = matchActivityToSegments(activityId);
|
||||||
id: activityId,
|
uploaded.push({ id: activityId, name, newPeaks, segments });
|
||||||
name: parsed?.name ?? gpx.name ?? file.name,
|
|
||||||
newPeaks
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
errors.push(`${file.name}: ${err instanceof Error ? err.message : 'could not parse'}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@
|
|||||||
{#if item.newPeaks.length > 0}
|
{#if item.newPeaks.length > 0}
|
||||||
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
|
<span class="bagged">⛰ Peak{item.newPeaks.length > 1 ? 's' : ''} reached: {item.newPeaks.join(', ')}!</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.segments.length > 0}
|
||||||
|
<span class="bagged">⏱ Segment{item.segments.length > 1 ? 's' : ''} matched: {item.segments.join(', ')}</span>
|
||||||
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
{#each form.errors as message (message)}
|
{#each form.errors as message (message)}
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { countAscents, getProfile, listActivities, listAscents, userStats } from '$lib/server/db';
|
import {
|
||||||
|
countAscents,
|
||||||
|
getProfile,
|
||||||
|
listActivities,
|
||||||
|
listAscents,
|
||||||
|
userSegments,
|
||||||
|
userStats
|
||||||
|
} from '$lib/server/db';
|
||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = ({ params }) => {
|
export const load: PageServerLoad = ({ params }) => {
|
||||||
@@ -12,6 +19,7 @@ export const load: PageServerLoad = ({ params }) => {
|
|||||||
stats: userStats(profile.id),
|
stats: userStats(profile.id),
|
||||||
peaksReached: countAscents(profile.id),
|
peaksReached: countAscents(profile.id),
|
||||||
highestAscent: ascents[0] ?? null,
|
highestAscent: ascents[0] ?? null,
|
||||||
|
segments: userSegments(profile.id),
|
||||||
activities: listActivities(profile.id).slice(0, 10)
|
activities: listActivities(profile.id).slice(0, 10)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import Avatar from '$lib/components/Avatar.svelte';
|
import Avatar from '$lib/components/Avatar.svelte';
|
||||||
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
import ActivityItem from '$lib/components/ActivityItem.svelte';
|
||||||
import StatTile from '$lib/components/StatTile.svelte';
|
import StatTile from '$lib/components/StatTile.svelte';
|
||||||
import { fmtDistance, fmtDuration, fmtElevation } from '$lib/format';
|
import { fmtDistance, fmtDuration, fmtElapsed, fmtElevation } from '$lib/format';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
const p = $derived(data.profile);
|
const p = $derived(data.profile);
|
||||||
@@ -41,6 +41,28 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if data.segments.length > 0}
|
||||||
|
<h2>Segments</h2>
|
||||||
|
<div class="card seg-list">
|
||||||
|
{#each data.segments as segment (segment.id)}
|
||||||
|
<a class="seg-row" href="/segments/{segment.id}">
|
||||||
|
<span class="seg-name">
|
||||||
|
{#if segment.rank === 1}<span class="crown" title="Course record">👑</span>{/if}
|
||||||
|
{segment.name}
|
||||||
|
</span>
|
||||||
|
<span class="seg-meta">
|
||||||
|
{fmtDistance(segment.distance_m)} · {segment.attempts}
|
||||||
|
{segment.attempts === 1 ? 'attempt' : 'attempts'}
|
||||||
|
{#if segment.best_s !== null}
|
||||||
|
· best {fmtElapsed(segment.best_s)}{#if segment.best_type} ({segment.best_type}){/if}
|
||||||
|
{#if segment.rank !== null}· #{segment.rank}{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Recent activities</h2>
|
<h2>Recent activities</h2>
|
||||||
{#if data.activities.length === 0}
|
{#if data.activities.length === 0}
|
||||||
<div class="card empty">No activities yet.</div>
|
<div class="card empty">No activities yet.</div>
|
||||||
@@ -91,6 +113,32 @@
|
|||||||
.list :global(.item:not(:last-child)) {
|
.list :global(.item:not(:last-child)) {
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
.seg-list > .seg-row:not(:last-child) {
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.seg-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.75rem 1.15rem;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
transition: background 120ms;
|
||||||
|
}
|
||||||
|
.seg-row:hover {
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
.seg-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
}
|
||||||
|
.crown {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.seg-meta {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
.empty {
|
.empty {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user