diff options
Diffstat (limited to 'viewer/js/measure.js')
| -rw-r--r-- | viewer/js/measure.js | 99 |
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)}`)); +} |
