@@ -6,7 +6,7 @@ import { type Site, type SplatParams } from './types.ts';
66import { cloneObject } from './utils.ts' ;
77import { draftPinElement , sitePinElement , targetPinElement } from './layers.ts' ;
88import { 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' ;
1010import { SearchControl } from './map/search.ts' ;
1111import { coverageImage , cropToRadius } from './map/overlay.ts' ;
1212import { coverageContours } from './map/contours.ts' ;
@@ -37,6 +37,12 @@ let linkEscHandler: ((e: KeyboardEvent) => void) | undefined;
3737let linkClickHandler : ( ( e : maplibregl . MapMouseEvent ) => void ) | undefined ;
3838let linkAbort : AbortController | undefined ;
3939const 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). */
4248function 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+
5875function 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 ) ) ;
0 commit comments