aboutsummaryrefslogtreecommitdiffziptar.gz
path: root/viewer/js/measure.js
diff options
context:
space:
mode:
authorgodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
committergodosa <godosa@godosa.eu>2026-10-07 00:14:38 +0200
commit3443c1c65e9f1753e1e656b35d08416c1fa298f2 (patch)
tree4e43236f460145a4d75d1b4616dcb7aa6ef08f51 /viewer/js/measure.js
downloadworldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.tar.gz
worldmap-viewer-3443c1c65e9f1753e1e656b35d08416c1fa298f2.zip
worldmap-viewer: initial public history
Diffstat (limited to 'viewer/js/measure.js')
-rw-r--r--viewer/js/measure.js99
1 files changed, 99 insertions, 0 deletions
diff --git a/viewer/js/measure.js b/viewer/js/measure.js
new file mode 100644
index 0000000..dee8329
--- /dev/null
+++ b/viewer/js/measure.js
@@ -0,0 +1,99 @@
+// measure.js — great-circle measuring path (pure) and its panel
+import { distanceKm, polygonAreaKm2 } from './geo.js';
+import { fmtDistance, fmtArea, fmtWalk, fmtElev } from './units.js';
+import { el, svg } from './ui.js';
+import { profileModel, sampleAt } from './profile.js';
+
+export class MeasurePath {
+ constructor() {
+ this.points = [];
+ this.closed = false;
+ }
+
+ add(p) {
+ if (this.closed) this.clear();
+ const last = this.points.at(-1);
+ if (last && last.lat === p.lat && last.lon === p.lon) return; // double-click lands twice
+ this.points.push({ lat: p.lat, lon: p.lon });
+ }
+
+ undo() {
+ if (this.closed) this.closed = false;
+ else this.points.pop();
+ }
+
+ clear() {
+ this.points = [];
+ this.closed = false;
+ }
+
+ close() {
+ if (this.points.length >= 3) this.closed = true;
+ return this.closed;
+ }
+
+ #ring() { return this.closed ? [...this.points, this.points[0]] : this.points; }
+
+ segmentsKm() {
+ const r = this.#ring();
+ return r.slice(1).map((p, i) => distanceKm(r[i], p));
+ }
+
+ totalKm() { return this.segmentsKm().reduce((a, b) => a + b, 0); }
+ areaKm2() { return this.closed ? polygonAreaKm2(this.points) : null; }
+ lines() { return this.points.length < 2 ? [] : [[...this.#ring()]]; }
+}
+
+export function renderMeasure(box, path, { undo, close, clear }) {
+ const segs = path.segmentsKm(), n = path.points.length;
+ const profile = el('div', { class: 'profile-box' });
+ box.replaceChildren(...[ // replaceChildren would print null as text: drop the absent parts
+ el('p', { class: 'muted' }, 'Click the map to add points; Esc clears.'),
+ el('div', {}, el('button', { onclick: undo, disabled: !n }, 'Undo'), ' ',
+ el('button', { onclick: close, disabled: n < 3 || path.closed }, 'Close for area'), ' ',
+ el('button', { onclick: clear, disabled: !n }, 'Clear')),
+ segs.length ? el('ol', {}, segs.map(km => el('li', {}, fmtDistance(km)))) : null,
+ segs.length ? el('p', {}, el('b', {}, 'Total '), fmtDistance(path.totalKm()), el('br'),
+ el('span', { class: 'muted' }, `${fmtWalk(path.totalKm())} (30 km a day)`)) : null,
+ path.closed ? el('p', {}, el('b', {}, 'Area '), fmtArea(path.areaKm2())) : null,
+ profile,
+ ].filter(Boolean));
+ return profile;
+}
+
+const CW = 320, CH = 130;
+const int = v => Math.round(v).toLocaleString('en-US');
+
+export function renderProfile(box, prof, onHover) {
+ const s = prof.samples, m = profileModel(s, CW, CH);
+ const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 8, y2: CH - 18, visibility: 'hidden' });
+ const readout = el('div', { class: 'readout muted' }, ' ');
+ const chart = svg('svg', { class: 'profile', viewBox: `0 0 ${CW} ${CH}`, width: CW, height: CH },
+ ...m.water.map(([a, b]) => svg('rect', { class: 'water', x: a, y: 8, width: Math.max(1, b - a), height: CH - 26 })),
+ svg('line', { class: 'sea', x1: m.X(0), x2: m.X(m.L), y1: m.seaY, y2: m.seaY }),
+ svg('path', { class: 'area', d: m.area }),
+ svg('path', { class: 'line', d: m.line }),
+ svg('text', { x: 2, y: 14 }, `${int(m.hi)} m`),
+ svg('text', { x: 2, y: CH - 20 }, `${int(m.lo)} m`),
+ svg('text', { x: CW - 4, y: CH - 4, 'text-anchor': 'end' }, fmtDistance(m.L)),
+ cursor);
+ chart.addEventListener('pointermove', e => {
+ const r = chart.getBoundingClientRect();
+ const p = s[sampleAt(s, m.xAt(((e.clientX - r.left) * CW) / r.width))];
+ const x = m.X(p.d_km);
+ cursor.setAttribute('x1', x);
+ cursor.setAttribute('x2', x);
+ cursor.setAttribute('visibility', 'visible');
+ readout.textContent = `${fmtDistance(p.d_km)} · ${fmtElev(p.z_m)}${p.water ? ' · water' : ''}`;
+ onHover(p);
+ });
+ chart.addEventListener('pointerleave', () => {
+ cursor.setAttribute('visibility', 'hidden');
+ readout.textContent = ' ';
+ onHover(null);
+ });
+ const st = prof.stats;
+ box.replaceChildren(chart, readout,
+ el('p', {}, `Highest ${fmtElev(st.max_m)} · lowest ${fmtElev(st.min_m)}`, el('br'),
+ `Climb ${int(st.ascent_m)} m · descent ${int(st.descent_m)} m · over water ${fmtDistance(st.water_km)}`));
+}