// 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)}`)); }