@@ -302,6 +302,22 @@ class PlowMap {
302302 this . map . removeSource ( "coverage-heatmap" ) ;
303303 }
304304
305+ /* ── Type filtering ─────────────────────────────── */
306+
307+ setTypeFilter ( filter ) {
308+ const layerIds = [
309+ "vehicle-outline" , "vehicle-circles" ,
310+ "mini-trails" ,
311+ "coverage-lines" , "coverage-heatmap" ,
312+ "vehicle-trail-dots" , "vehicle-trail-line" ,
313+ ] ;
314+ for ( const id of layerIds ) {
315+ if ( this . map . getLayer ( id ) ) {
316+ this . map . setFilter ( id , filter ) ;
317+ }
318+ }
319+ }
320+
305321 /* ── Abort management ───────────────────────────── */
306322
307323 abortCoverage ( ) {
@@ -390,6 +406,7 @@ const VEHICLE_COLORS = {
390406 GRADER : "#16a34a" ,
391407} ;
392408const DEFAULT_COLOR = "#6b7280" ;
409+ const KNOWN_TYPES = [ "SA PLOW TRUCK" , "TA PLOW TRUCK" , "LOADER" , "GRADER" ] ;
393410
394411function vehicleColor ( type ) {
395412 return VEHICLE_COLORS [ type ] || DEFAULT_COLOR ;
@@ -410,7 +427,7 @@ function buildMiniTrails(data) {
410427 type : "LineString" ,
411428 coordinates : [ trail [ i ] , trail [ i + 1 ] ] ,
412429 } ,
413- properties : { color, opacity } ,
430+ properties : { color, opacity, vehicle_type : f . properties . vehicle_type } ,
414431 } ) ;
415432 }
416433 }
@@ -523,6 +540,7 @@ function buildTrailSegments(features) {
523540 properties : {
524541 seg_opacity : features [ i ] . properties . trail_opacity ,
525542 seg_color : features [ i ] . properties . trail_color ,
543+ vehicle_type : features [ i ] . properties . vehicle_type ,
526544 } ,
527545 } ) ;
528546 }
@@ -534,8 +552,16 @@ function buildTrailSegments(features) {
534552const btnRealtime = document . getElementById ( "btn-realtime" ) ;
535553const btnCoverage = document . getElementById ( "btn-coverage" ) ;
536554const coveragePanelEl = document . getElementById ( "coverage-panel" ) ;
537- const timeSlider = document . getElementById ( "time-slider" ) ;
555+ const timeSliderEl = document . getElementById ( "time-slider" ) ;
538556const sliderLabel = document . getElementById ( "slider-label" ) ;
557+
558+ // Initialize noUiSlider with dual handles (0-1000 internal range)
559+ noUiSlider . create ( timeSliderEl , {
560+ start : [ 0 , 1000 ] ,
561+ connect : true ,
562+ range : { min : 0 , max : 1000 } ,
563+ step : 1 ,
564+ } ) ;
539565const coverageLoading = document . getElementById ( "coverage-loading" ) ;
540566const coverageRangeLabel = document . getElementById ( "coverage-range-label" ) ;
541567const datePickerRow = document . getElementById ( "date-picker-row" ) ;
@@ -565,8 +591,8 @@ function setPresetActive(value) {
565591}
566592
567593function showLegend ( type ) {
568- document . getElementById ( " legend-vehicles" ) . style . display =
569- type === " vehicles" ? "" : "none ";
594+ // Vehicle legend (with type checkboxes) is always visible
595+ document . getElementById ( "legend- vehicles") . style . display = " ";
570596 document . getElementById ( "legend-heatmap" ) . style . display =
571597 type === "heatmap" ? "" : "none" ;
572598}
@@ -616,6 +642,45 @@ class PlowApp {
616642 this . coverageView = "lines" ;
617643 }
618644
645+ /* ── Type filtering ─────────────────────────────── */
646+
647+ buildTypeFilter ( ) {
648+ const checked = [ ] ;
649+ let otherChecked = false ;
650+ for ( const row of document . querySelectorAll ( "#legend-vehicles .legend-row" ) ) {
651+ const cb = row . querySelector ( ".legend-check" ) ;
652+ if ( ! cb . checked ) continue ;
653+ const types = row . dataset . types ;
654+ if ( types === "__OTHER__" ) {
655+ otherChecked = true ;
656+ } else {
657+ checked . push ( ...types . split ( "," ) ) ;
658+ }
659+ }
660+
661+ // If everything is checked, no filter needed
662+ if ( checked . length === KNOWN_TYPES . length && otherChecked ) {
663+ return null ;
664+ }
665+
666+ // Build a filter: include checked known types + "other" (not in KNOWN_TYPES)
667+ const parts = [ ] ;
668+ if ( checked . length > 0 ) {
669+ parts . push ( [ "in" , [ "get" , "vehicle_type" ] , [ "literal" , checked ] ] ) ;
670+ }
671+ if ( otherChecked ) {
672+ parts . push ( [ "!" , [ "in" , [ "get" , "vehicle_type" ] , [ "literal" , KNOWN_TYPES ] ] ] ) ;
673+ }
674+
675+ if ( parts . length === 0 ) return false ; // nothing visible
676+ if ( parts . length === 1 ) return parts [ 0 ] ;
677+ return [ "any" , ...parts ] ;
678+ }
679+
680+ applyTypeFilters ( ) {
681+ this . map . setTypeFilter ( this . buildTypeFilter ( ) ) ;
682+ }
683+
619684 /* ── Mode switching ────────────────────────────── */
620685
621686 async switchMode ( mode ) {
@@ -674,7 +739,7 @@ class PlowApp {
674739 this . coverageUntil = until ;
675740 this . updateRangeLabel ( ) ;
676741 coverageLoading . style . display = "block" ;
677- timeSlider . value = 1000 ;
742+ timeSliderEl . noUiSlider . set ( [ 0 , 1000 ] ) ;
678743 this . map . clearCoverage ( ) ;
679744 try {
680745 const resp = await fetch (
@@ -687,7 +752,8 @@ class PlowApp {
687752 throw err ;
688753 }
689754 coverageLoading . style . display = "none" ;
690- this . renderCoverage ( 1000 ) ;
755+ this . renderCoverage ( 0 , 1000 ) ;
756+ this . applyTypeFilters ( ) ;
691757 }
692758
693759 async loadCoverageForDate ( dateStr ) {
@@ -703,28 +769,33 @@ class PlowApp {
703769 btnLines . classList . toggle ( "active" , view === "lines" ) ;
704770 btnHeatmap . classList . toggle ( "active" , view === "heatmap" ) ;
705771 showLegend ( view === "heatmap" ? "heatmap" : "vehicles" ) ;
706- this . renderCoverage ( parseInt ( timeSlider . value ) ) ;
772+ const vals = timeSliderEl . noUiSlider . get ( ) . map ( Number ) ;
773+ this . renderCoverage ( vals [ 0 ] , vals [ 1 ] ) ;
774+ this . applyTypeFilters ( ) ;
707775 }
708776
709- renderCoverage ( sliderVal ) {
777+ renderCoverage ( fromVal , toVal ) {
710778 if ( ! this . coverageData || this . mode !== "coverage" ) return ;
711- const cutoff = this . sliderToTime ( sliderVal ) ;
712- sliderLabel . textContent = formatTimestamp ( cutoff . toISOString ( ) ) ;
779+ const fromTime = this . sliderToTime ( fromVal ) ;
780+ const toTime = this . sliderToTime ( toVal ) ;
781+ sliderLabel . innerHTML =
782+ "<span>" + formatTimestamp ( fromTime . toISOString ( ) ) + "</span>" +
783+ "<span>" + formatTimestamp ( toTime . toISOString ( ) ) + "</span>" ;
713784
714785 if ( this . coverageView === "lines" ) {
715786 this . map . setHeatmapVisibility ( false ) ;
716- this . renderCoverageLines ( sliderVal , cutoff ) ;
787+ this . renderCoverageLines ( fromTime , toTime ) ;
717788 this . map . setCoverageLineVisibility ( true ) ;
718789 } else {
719790 this . map . setCoverageLineVisibility ( false ) ;
720- this . renderHeatmap ( sliderVal ) ;
791+ this . renderHeatmap ( fromTime , toTime ) ;
721792 this . map . setHeatmapVisibility ( true ) ;
722793 }
723794 }
724795
725- renderCoverageLines ( sliderVal , cutoff ) {
726- const sinceMs = this . coverageSince . getTime ( ) ;
727- const rangeMs = cutoff . getTime ( ) - sinceMs ;
796+ renderCoverageLines ( fromTime , toTime ) {
797+ const fromMs = fromTime . getTime ( ) ;
798+ const rangeMs = toTime . getTime ( ) - fromMs ;
728799
729800 const segmentFeatures = [ ] ;
730801 for ( const feature of this . coverageData . features ) {
@@ -735,9 +806,10 @@ class PlowApp {
735806 for ( let i = 0 ; i < coords . length - 1 ; i ++ ) {
736807 const tMs = new Date ( timestamps [ i ] ) . getTime ( ) ;
737808 const tNextMs = new Date ( timestamps [ i + 1 ] ) . getTime ( ) ;
738- if ( tNextMs > cutoff . getTime ( ) ) break ;
809+ if ( tMs < fromMs ) continue ;
810+ if ( tNextMs > toTime . getTime ( ) ) break ;
739811
740- const progress = rangeMs > 0 ? ( tMs - sinceMs ) / rangeMs : 1 ;
812+ const progress = rangeMs > 0 ? ( tMs - fromMs ) / rangeMs : 1 ;
741813 const opacity = 0.15 + progress * 0.65 ;
742814
743815 segmentFeatures . push ( {
@@ -746,7 +818,7 @@ class PlowApp {
746818 type : "LineString" ,
747819 coordinates : [ coords [ i ] , coords [ i + 1 ] ] ,
748820 } ,
749- properties : { seg_opacity : opacity , seg_color : color } ,
821+ properties : { seg_opacity : opacity , seg_color : color , vehicle_type : feature . properties . vehicle_type } ,
750822 } ) ;
751823 }
752824 }
@@ -755,21 +827,23 @@ class PlowApp {
755827 this . map . renderCoverageLines ( data ) ;
756828 }
757829
758- renderHeatmap ( sliderVal ) {
830+ renderHeatmap ( fromTime , toTime ) {
759831 if ( ! this . coverageData ) return ;
760- const cutoff = this . sliderToTime ( sliderVal ) ;
832+ const fromMs = fromTime . getTime ( ) ;
833+ const toMs = toTime . getTime ( ) ;
761834
762835 const pointFeatures = [ ] ;
763836 for ( const feature of this . coverageData . features ) {
764837 const coords = feature . geometry . coordinates ;
765838 const timestamps = feature . properties . timestamps ;
766839 for ( let i = 0 ; i < coords . length ; i ++ ) {
767840 const tMs = new Date ( timestamps [ i ] ) . getTime ( ) ;
768- if ( tMs > cutoff . getTime ( ) ) break ;
841+ if ( tMs < fromMs ) continue ;
842+ if ( tMs > toMs ) break ;
769843 pointFeatures . push ( {
770844 type : "Feature" ,
771845 geometry : { type : "Point" , coordinates : coords [ i ] } ,
772- properties : { } ,
846+ properties : { vehicle_type : feature . properties . vehicle_type } ,
773847 } ) ;
774848 }
775849 }
@@ -864,6 +938,7 @@ class PlowApp {
864938 } ;
865939
866940 this . map . showTrail ( trailData , lineData ) ;
941+ this . applyTypeFilters ( ) ;
867942 }
868943
869944 async refreshTrail ( ) {
@@ -920,8 +995,14 @@ btnLines.addEventListener("click", () => app.switchCoverageView("lines"));
920995btnHeatmap . addEventListener ( "click" , ( ) => app . switchCoverageView ( "heatmap" ) ) ;
921996
922997// Slider
923- timeSlider . addEventListener ( "input" , ( e ) => {
924- app . renderCoverage ( parseInt ( e . target . value ) ) ;
998+ timeSliderEl . noUiSlider . on ( "update" , ( ) => {
999+ const vals = timeSliderEl . noUiSlider . get ( ) . map ( Number ) ;
1000+ app . renderCoverage ( vals [ 0 ] , vals [ 1 ] ) ;
1001+ } ) ;
1002+
1003+ // Legend type checkboxes
1004+ document . getElementById ( "legend-vehicles" ) . addEventListener ( "change" , ( ) => {
1005+ app . applyTypeFilters ( ) ;
9251006} ) ;
9261007
9271008// Detail close
0 commit comments