Skip to content

Commit e117c95

Browse files
committed
legend filtering, better slider
1 parent ae41e5f commit e117c95

4 files changed

Lines changed: 182 additions & 48 deletions

File tree

src/where_the_plow/main.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,11 @@ async def lifespan(app: FastAPI):
4242
app = FastAPI(
4343
title="Where the Plow",
4444
description="Real-time and historical plow tracker for the City of St. John's. "
45-
"All geo endpoints return GeoJSON FeatureCollections with cursor-based pagination.",
45+
"All geo endpoints return GeoJSON FeatureCollections with cursor-based pagination.\n\n"
46+
"**WARNING:** This API is not stable. Monitor the `openapi.json` file for breaking changes. "
47+
"Version 0.1.0 will remain until the API can be considered stable — and even then, "
48+
"it likely won't hit stable unless someone asks nicely. If you would like a stable API, "
49+
"shoot [jackharrhy.dev](https://jackharrhy.dev) an email.",
4650
version="0.1.0",
4751
lifespan=lifespan,
4852
)

src/where_the_plow/static/app.js

Lines changed: 105 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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
};
392408
const DEFAULT_COLOR = "#6b7280";
409+
const KNOWN_TYPES = ["SA PLOW TRUCK", "TA PLOW TRUCK", "LOADER", "GRADER"];
393410

394411
function 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) {
534552
const btnRealtime = document.getElementById("btn-realtime");
535553
const btnCoverage = document.getElementById("btn-coverage");
536554
const coveragePanelEl = document.getElementById("coverage-panel");
537-
const timeSlider = document.getElementById("time-slider");
555+
const timeSliderEl = document.getElementById("time-slider");
538556
const 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+
});
539565
const coverageLoading = document.getElementById("coverage-loading");
540566
const coverageRangeLabel = document.getElementById("coverage-range-label");
541567
const datePickerRow = document.getElementById("date-picker-row");
@@ -565,8 +591,8 @@ function setPresetActive(value) {
565591
}
566592

567593
function 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"));
920995
btnHeatmap.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

src/where_the_plow/static/index.html

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@
2020
rel="stylesheet"
2121
href="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.css"
2222
/>
23+
<link
24+
rel="stylesheet"
25+
href="https://unpkg.com/nouislider@15/dist/nouislider.min.css"
26+
/>
2327
<link rel="stylesheet" href="/static/style.css" />
2428
<script src="https://unpkg.com/maplibre-gl@5/dist/maplibre-gl.js"></script>
29+
<script src="https://unpkg.com/nouislider@15/dist/nouislider.min.js"></script>
2530
</head>
2631
<body>
2732
<div id="map"></div>
@@ -44,6 +49,7 @@ <h3>Where the Plow</h3>
4449
<div class="detail-row" id="detail-updated"></div>
4550
</div>
4651
<div id="coverage-panel">
52+
<div class="coverage-hint">Load coverage data for a time window</div>
4753
<div id="time-range-presets" class="toggle-group">
4854
<button data-hours="6">6h</button>
4955
<button data-hours="12">12h</button>
@@ -54,19 +60,14 @@ <h3>Where the Plow</h3>
5460
<input type="date" id="coverage-date" />
5561
</div>
5662
<div id="coverage-range-label"></div>
63+
<div class="coverage-hint">View as route lines or activity heatmap</div>
5764
<div id="coverage-view-toggle" class="toggle-group">
5865
<button id="btn-lines" class="active">Lines</button>
5966
<button id="btn-heatmap">Heatmap</button>
6067
</div>
61-
<label>Up to:</label>
68+
<div class="coverage-hint">Drag the handles to narrow the visible time range</div>
6269
<span id="slider-label"></span>
63-
<input
64-
type="range"
65-
id="time-slider"
66-
min="0"
67-
max="1000"
68-
value="1000"
69-
/>
70+
<div id="time-slider"></div>
7071
<div id="coverage-loading">Loading coverage data...</div>
7172
</div>
7273
<div id="legend">
@@ -78,34 +79,38 @@ <h3>Where the Plow</h3>
7879
</div>
7980
<div id="legend-body">
8081
<div id="legend-vehicles">
81-
<div class="legend-row">
82+
<label class="legend-row" data-types="SA PLOW TRUCK,TA PLOW TRUCK">
8283
<span
8384
class="legend-swatch"
8485
style="background: #2563eb"
8586
></span
8687
>Plow truck
87-
</div>
88-
<div class="legend-row">
88+
<input type="checkbox" checked class="legend-check" />
89+
</label>
90+
<label class="legend-row" data-types="LOADER">
8991
<span
9092
class="legend-swatch"
9193
style="background: #ea580c"
9294
></span
9395
>Loader
94-
</div>
95-
<div class="legend-row">
96+
<input type="checkbox" checked class="legend-check" />
97+
</label>
98+
<label class="legend-row" data-types="GRADER">
9699
<span
97100
class="legend-swatch"
98101
style="background: #16a34a"
99102
></span
100103
>Grader
101-
</div>
102-
<div class="legend-row">
104+
<input type="checkbox" checked class="legend-check" />
105+
</label>
106+
<label class="legend-row" data-types="__OTHER__">
103107
<span
104108
class="legend-swatch"
105109
style="background: #6b7280"
106110
></span
107111
>Other
108-
</div>
112+
<input type="checkbox" checked class="legend-check" />
113+
</label>
109114
</div>
110115
<div id="legend-heatmap" style="display: none">
111116
<div class="legend-gradient"></div>

0 commit comments

Comments
 (0)