Skip to content

Commit f25fee6

Browse files
thebenternclaude
andcommitted
Add a measure/ruler tool (#15)
A ruler button toggles measure mode (crosshair + highlighted control); click two points to read great-circle distance + bearing in a readout, with a dashed line and endpoint dots on the map. Esc / Done exits; the next click starts a fresh measurement. Also hardens the line/marker draw path (measure + point-to-point): add the GL source/layer via try/catch with an idle retry instead of gating on isStyleLoaded(), which can stay false while tiles stream. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1125475 commit f25fee6

4 files changed

Lines changed: 196 additions & 9 deletions

File tree

src/App.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@
150150
<span><span class="mt-place-dot mt-place-dot-target"></span>Click the map to place the link target</span>
151151
<button type="button" class="mt-place-cancel" @click="store.cancelPlaceTarget()">Cancel (Esc)</button>
152152
</div>
153+
154+
<div v-if="store.measureMode" class="mt-place-hint" role="status">
155+
<span v-if="store.measureResult"><span class="mt-place-dot"></span>{{ store.measureResult.distanceKm.toFixed(2) }} km · {{ Math.round(store.measureResult.bearingDeg) }}&deg; — click to measure again</span>
156+
<span v-else><span class="mt-place-dot"></span>Click two points to measure distance</span>
157+
<button type="button" class="mt-place-cancel" @click="store.endMeasure()">Done (Esc)</button>
158+
</div>
153159
</div>
154160
</template>
155161

src/map/controls.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,40 @@
1-
/* Small custom MapLibre controls: basemap switcher and PNG export. */
1+
/* Small custom MapLibre controls: basemap switcher, PNG export, ruler. */
22

33
import type { IControl, Map as MlMap } from 'maplibre-gl';
44

5+
/** Toggle button for the measure/ruler tool (#15). State lives in the store;
6+
* setActive() keeps the button highlight in sync (e.g. when Esc exits). */
7+
export class MeasureControl implements IControl {
8+
private container?: HTMLElement;
9+
private button?: HTMLButtonElement;
10+
11+
constructor(private readonly onToggle: () => void) {}
12+
13+
onAdd(): HTMLElement {
14+
const div = document.createElement('div');
15+
div.className = 'maplibregl-ctrl maplibregl-ctrl-group';
16+
const button = document.createElement('button');
17+
button.type = 'button';
18+
button.title = 'Measure distance';
19+
button.setAttribute('aria-label', 'Measure distance');
20+
button.innerHTML =
21+
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 19 19 5"/><circle cx="5" cy="19" r="2"/><circle cx="19" cy="5" r="2"/></svg>';
22+
button.onclick = () => this.onToggle();
23+
div.appendChild(button);
24+
this.button = button;
25+
this.container = div;
26+
return div;
27+
}
28+
29+
setActive(active: boolean): void {
30+
this.button?.classList.toggle('mt-ctrl-active', active);
31+
}
32+
33+
onRemove(): void {
34+
this.container?.remove();
35+
}
36+
}
37+
538
/** Radio-style basemap switcher (replaces Leaflet's layers control). */
639
export class BasemapControl implements IControl {
740
private container?: HTMLElement;

src/store.ts

Lines changed: 150 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { type Site, type SplatParams } from './types.ts';
66
import { cloneObject } from './utils.ts';
77
import { draftPinElement, sitePinElement, targetPinElement } from './layers.ts';
88
import { BASEMAPS, DEFAULT_BASEMAP, applyBasemap, emptyStyle } from './map/styles.ts';
9-
import { BasemapControl, ExportControl } from './map/controls.ts';
9+
import { BasemapControl, ExportControl, MeasureControl } from './map/controls.ts';
1010
import { SearchControl } from './map/search.ts';
1111
import { coverageImage, cropToRadius } from './map/overlay.ts';
1212
import { coverageContours } from './map/contours.ts';
@@ -37,6 +37,12 @@ let linkEscHandler: ((e: KeyboardEvent) => void) | undefined;
3737
let linkClickHandler: ((e: maplibregl.MapMouseEvent) => void) | undefined;
3838
let linkAbort: AbortController | undefined;
3939
const LINK_LINE_ID = 'mt-p2p-link';
40+
// Measure/ruler tool (#15).
41+
let measureControl: MeasureControl | undefined;
42+
let measureClickHandler: ((e: maplibregl.MapMouseEvent) => void) | undefined;
43+
let measureEscHandler: ((e: KeyboardEvent) => void) | undefined;
44+
let measureA: { lat: number; lon: number } | null = null;
45+
const MEASURE_SRC = 'mt-measure';
4046

4147
/** Wrap a longitude into [-180, 180). */
4248
function wrapLon(lon: number): number {
@@ -55,6 +61,17 @@ function haversineKm(lat1: number, lon1: number, lat2: number, lon2: number): nu
5561
return 2 * R * Math.asin(Math.min(1, Math.sqrt(a)));
5662
}
5763

64+
/** Initial great-circle bearing from A to B, degrees (0 = north, clockwise). */
65+
function bearingDeg(lat1: number, lon1: number, lat2: number, lon2: number): number {
66+
const toRad = (d: number) => (d * Math.PI) / 180;
67+
const φ1 = toRad(lat1);
68+
const φ2 = toRad(lat2);
69+
const Δλ = toRad(lon2 - lon1);
70+
const y = Math.sin(Δλ) * Math.cos(φ2);
71+
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
72+
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
73+
}
74+
5875
function getEngine(): WasmCoverageEngine {
5976
engine ??= new WasmCoverageEngine();
6077
return engine;
@@ -190,6 +207,9 @@ const useStore = defineStore('store', {
190207
splatParams: initialParams(),
191208
/** Transient "Copied!" feedback for the share button (#9). */
192209
shareCopied: false,
210+
/** Measure/ruler tool (#15). */
211+
measureMode: false,
212+
measureResult: null as { distanceKm: number; bearingDeg: number } | null,
193213
}
194214
},
195215
actions: {
@@ -405,11 +425,20 @@ const useStore = defineStore('store', {
405425
});
406426
}
407427
};
408-
// Updating an existing source/layer is safe anytime; only adding one
409-
// needs the style loaded (a streaming raster basemap rarely reports
410-
// isStyleLoaded, so don't gate the recolor behind it).
411-
if (map.getSource(LINK_LINE_ID) || map.isStyleLoaded()) draw();
412-
else map.once('idle', draw);
428+
// addSource/addLayer succeed once the style spec is parsed (even while
429+
// tiles stream and isStyleLoaded() is false); only the brief initial
430+
// load / a basemap switch can throw, so try now and retry on idle.
431+
try {
432+
draw();
433+
} catch {
434+
map.once('idle', () => {
435+
try {
436+
draw();
437+
} catch {
438+
/* ignore */
439+
}
440+
});
441+
}
413442
},
414443

415444
/* ---- Find highpoint (#39) ---- */
@@ -471,6 +500,117 @@ const useStore = defineStore('store', {
471500
}
472501
},
473502

503+
/* ---- Measure / ruler tool (#15) ---- */
504+
toggleMeasure() {
505+
if (this.measureMode) {
506+
this.endMeasure();
507+
return;
508+
}
509+
this.cancelPlaceOnMap();
510+
this.cancelPlaceTarget();
511+
this.measureMode = true;
512+
this.measureResult = null;
513+
measureA = null;
514+
measureControl?.setActive(true);
515+
if (map) map.getCanvas().style.cursor = 'crosshair';
516+
measureClickHandler = (e: maplibregl.MapMouseEvent) => {
517+
const lat = e.lngLat.lat;
518+
const lon = wrapLon(e.lngLat.lng);
519+
if (!measureA) {
520+
measureA = { lat, lon };
521+
this.measureResult = null;
522+
this.drawMeasure(measureA, null);
523+
} else {
524+
const b = { lat, lon };
525+
this.measureResult = {
526+
distanceKm: haversineKm(measureA.lat, measureA.lon, b.lat, b.lon),
527+
bearingDeg: bearingDeg(measureA.lat, measureA.lon, b.lat, b.lon),
528+
};
529+
this.drawMeasure(measureA, b);
530+
measureA = null; // next click starts a new measurement
531+
}
532+
};
533+
map?.on('click', measureClickHandler);
534+
measureEscHandler = (ev: KeyboardEvent) => {
535+
if (ev.key === 'Escape') this.endMeasure();
536+
};
537+
window.addEventListener('keydown', measureEscHandler);
538+
},
539+
endMeasure() {
540+
this.measureMode = false;
541+
this.measureResult = null;
542+
measureA = null;
543+
measureControl?.setActive(false);
544+
if (map) map.getCanvas().style.cursor = '';
545+
if (measureClickHandler) {
546+
map?.off('click', measureClickHandler);
547+
measureClickHandler = undefined;
548+
}
549+
if (measureEscHandler) {
550+
window.removeEventListener('keydown', measureEscHandler);
551+
measureEscHandler = undefined;
552+
}
553+
if (map?.getLayer(`${MEASURE_SRC}-line`)) map.removeLayer(`${MEASURE_SRC}-line`);
554+
if (map?.getLayer(`${MEASURE_SRC}-pts`)) map.removeLayer(`${MEASURE_SRC}-pts`);
555+
if (map?.getSource(MEASURE_SRC)) map.removeSource(MEASURE_SRC);
556+
},
557+
drawMeasure(a: { lat: number; lon: number } | null, b: { lat: number; lon: number } | null) {
558+
if (!map) return;
559+
const features: GeoJSON.Feature[] = [];
560+
if (a) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [a.lon, a.lat] }, properties: {} });
561+
if (b) features.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [b.lon, b.lat] }, properties: {} });
562+
if (a && b)
563+
features.push({
564+
type: 'Feature',
565+
geometry: { type: 'LineString', coordinates: [[a.lon, a.lat], [b.lon, b.lat]] },
566+
properties: {},
567+
});
568+
const fc: GeoJSON.FeatureCollection = { type: 'FeatureCollection', features };
569+
const draw = () => {
570+
if (!map) return;
571+
const src = map.getSource(MEASURE_SRC) as maplibregl.GeoJSONSource | undefined;
572+
if (src) {
573+
src.setData(fc);
574+
return;
575+
}
576+
map.addSource(MEASURE_SRC, { type: 'geojson', data: fc });
577+
map.addLayer({
578+
id: `${MEASURE_SRC}-line`,
579+
type: 'line',
580+
source: MEASURE_SRC,
581+
filter: ['==', ['geometry-type'], 'LineString'],
582+
layout: { 'line-cap': 'round' },
583+
paint: { 'line-color': '#67ea94', 'line-width': 2.5, 'line-dasharray': [2, 1.5] },
584+
});
585+
map.addLayer({
586+
id: `${MEASURE_SRC}-pts`,
587+
type: 'circle',
588+
source: MEASURE_SRC,
589+
filter: ['==', ['geometry-type'], 'Point'],
590+
paint: {
591+
'circle-radius': 4,
592+
'circle-color': '#67ea94',
593+
'circle-stroke-color': '#0f1017',
594+
'circle-stroke-width': 2,
595+
},
596+
});
597+
};
598+
// addSource/addLayer succeed once the style spec is parsed (even while
599+
// tiles stream and isStyleLoaded() is false); only the brief initial
600+
// load / a basemap switch can throw, so try now and retry on idle.
601+
try {
602+
draw();
603+
} catch {
604+
map.once('idle', () => {
605+
try {
606+
draw();
607+
} catch {
608+
/* ignore */
609+
}
610+
});
611+
}
612+
},
613+
474614
removeSite(index: number) {
475615
const [removed] = this.localSites.splice(index, 1)
476616
if (removed) {
@@ -597,6 +737,8 @@ const useStore = defineStore('store', {
597737
'bottom-left'
598738
);
599739
map.addControl(new ExportControl(), 'bottom-left');
740+
measureControl = new MeasureControl(() => this.toggleMeasure());
741+
map.addControl(measureControl, 'bottom-left');
600742
map.addControl(
601743
new BasemapControl(Object.keys(BASEMAPS), DEFAULT_BASEMAP, (name) => {
602744
// Swap the basemap raster source/layers in place (keeps overlays,
@@ -623,7 +765,7 @@ const useStore = defineStore('store', {
623765
// the live coverage fill layers each click keeps it correct as sites
624766
// are added/removed; ignored while placing a transmitter.
625767
map.on('click', (e: maplibregl.MapMouseEvent) => {
626-
if (!map || this.placingMode || this.overlayStyle !== 'contours') return;
768+
if (!map || this.placingMode || this.measureMode || this.overlayStyle !== 'contours') return;
627769
const layerIds = this.localSites
628770
.map((s) => `coverage-${s.id}`)
629771
.filter((id) => map!.getLayer(id));
@@ -643,7 +785,7 @@ const useStore = defineStore('store', {
643785
});
644786
map.on('mousemove', (e: maplibregl.MapMouseEvent) => {
645787
// Leave the cursor alone while placing (crosshair) or in heatmap mode.
646-
if (!map || this.placingMode || this.overlayStyle !== 'contours') return;
788+
if (!map || this.placingMode || this.measureMode || this.overlayStyle !== 'contours') return;
647789
const layerIds = this.localSites
648790
.map((s) => `coverage-${s.id}`)
649791
.filter((id) => map!.getLayer(id));

src/style.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,12 @@ body {
429429
filter: none; /* keep the active-state blue legible */
430430
}
431431

432+
/* Active state for our toggle controls (the measure/ruler tool). */
433+
.maplibregl-ctrl button.mt-ctrl-active {
434+
background: var(--mt-primary);
435+
color: var(--mt-on-primary);
436+
}
437+
432438
.maplibregl-ctrl-scale {
433439
background: rgba(26, 27, 38, 0.7);
434440
color: var(--mt-on-surface);

0 commit comments

Comments
 (0)