Created
June 2, 2026 14:58
-
-
Save thomaswilburn/c386897e679ab284022f0ecbeab4c5d2 to your computer and use it in GitHub Desktop.
Animated dots over time on a Leaflet map
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| var { isMobile } = require("./lib/breakpoints"); | |
| var { Sidechain } = require("@nprapps/sidechain"); | |
| Sidechain.registerGuest(); | |
| var $ = require("./lib/qsa"); | |
| var L = require("leaflet"); | |
| var colors = require("./lib/helpers/colors"); | |
| const TICK_RATE = 1200; | |
| const SCHOOLS = window.DATA.filter(r => r.latlng); | |
| console.clear(); | |
| var mapElement = $.one(".map-container"); | |
| mapElement.focus = () => HTMLElement.prototype.focus.call(mapElement, { preventScroll: true }); | |
| var map = new L.Map(mapElement, { | |
| zoomSnap: .3, | |
| scrollWheelZoom: false | |
| }); | |
| L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { | |
| subdomains: "abcd".split("") | |
| }).addTo(map); | |
| var markerLookup = {}; | |
| var markerBounds = L.latLngBounds(); | |
| var labels = { | |
| year: $.one("#year-text"), | |
| open: $.one("#open-count"), | |
| closed: $.one("#close-count") | |
| }; | |
| // called on every tick | |
| function updateMarkers(year) { | |
| // counters | |
| var all_facilities = SCHOOLS.length; | |
| var unopened_facilities = 0; | |
| var closed_facilities = 0; | |
| for (var school of SCHOOLS) { | |
| var { marker, year_min, year_max } = school; | |
| // default styles | |
| var className = "school-icon visible"; | |
| var fillOpacity = 1; | |
| var color = colors.peach2; | |
| var fillColor = color; | |
| var radius = 6; | |
| var weight = 2; | |
| if (year < year_min) { | |
| // hide schools that aren't open yet via CSS | |
| className = "school-icon invisible"; | |
| // count not-yet-open facilities to subtract from total | |
| unopened_facilities += 1 | |
| } else if (year > year_max) { | |
| // handle schools that are closed; color is set in index.html | |
| fillOpacity = .8; | |
| weight = 1; | |
| radius = 4; | |
| color = colors.purple1; | |
| fillColor = "url(#crosshatch)"; | |
| // count facilities that have shut down | |
| closed_facilities += 1 | |
| } | |
| marker.setStyle({ fillOpacity, fillColor, color, className, weight }) | |
| marker.setRadius(radius); | |
| } | |
| labels.year.innerHTML = year; | |
| labels.open.innerHTML = all_facilities - unopened_facilities - closed_facilities; | |
| labels.closed.innerHTML = closed_facilities; | |
| } | |
| // add markers and set up data structures | |
| for (var school of SCHOOLS) { | |
| var [ lat, lng ] = school.latlng.split(",").map(Number); | |
| var details = window.DIRECTORY[school.name] || {} | |
| let tooltipContents = `<b>${school.name}</b><br> | |
| ${school.year_min > '2015' ? `Opened in ${school.year_min}.` : 'Open prior to 2015.'} | |
| ${school.year_max < '2022' ? `Closed in ${school.year_max}.` : 'Had not closed through 2022.' } | |
| <ul> | |
| <li><em>Placing Agencies:</em> ${details.placing_agencies}</li> | |
| <li><em>Genders Served:</em> ${details.gender}</li> | |
| <li><em>Focus:</em> ${details.focus}</li> | |
| </ul>`; | |
| var marker = L.circleMarker([lat, lng], { | |
| className: "school-icon", | |
| radius: 10 | |
| }).bindPopup(tooltipContents) | |
| .addTo(map); | |
| marker.on("click", function() { | |
| tick.stop(); | |
| }); | |
| markerLookup[school.name] = marker; | |
| marker.data = school; | |
| markerBounds.extend([lat, lng]); | |
| school.marker = marker; | |
| }; | |
| var slider = $.one(".slider input"); | |
| var label = $.one(".slider label"); | |
| slider.addEventListener("input", e => { | |
| var year = slider.value; | |
| updateMarkers(year); | |
| label.innerHTML = year; | |
| tick.stop(); | |
| }); | |
| requestAnimationFrame(() => updateMarkers(slider.value)); | |
| map.fitBounds(markerBounds, { padding: [20, 20] }); | |
| window.map = map; | |
| var zoom = map.zoomControl; | |
| function onPopupStatus(e) { | |
| if (e.type == "popupopen") { | |
| zoom.remove(); | |
| } else { | |
| zoom.addTo(map); | |
| } | |
| } | |
| "popupopen popupclose".split(" ").forEach(e => map.on(e, onPopupStatus)); | |
| // the clock provides tick events at regular intervals, and can be paused on interaction | |
| class Clock extends EventTarget { | |
| interval = null; | |
| running = false; | |
| constructor(callback) { | |
| super(); | |
| this.callback = callback; | |
| } | |
| start(time) { | |
| this.running = true; | |
| this.interval = window.setInterval(this.callback, time); | |
| this.dispatchEvent(new CustomEvent("clockchange")); | |
| } | |
| stop() { | |
| this.running = false; | |
| clearInterval(this.interval); | |
| this.interval = null; | |
| this.dispatchEvent(new CustomEvent("clockchange")); | |
| } | |
| } | |
| var hold = false; | |
| function onTick() { | |
| var { value, min, max } = slider; | |
| value++; | |
| if (value > max) { | |
| // pause for a second on the last item | |
| if (hold) { | |
| hold = false; | |
| value = min; | |
| } else { | |
| hold = true; | |
| value = max; | |
| } | |
| } | |
| slider.value = value; | |
| updateMarkers(value); | |
| label.innerHTML = value; | |
| } | |
| var playButton = $.one(".animation-toggle"); | |
| var tick = new Clock(onTick); | |
| tick.addEventListener("clockchange", function(e) { | |
| playButton.innerHTML = tick.running ? "Pause" : "Play"; | |
| }); | |
| tick.start(TICK_RATE); | |
| playButton.addEventListener("click", function() { | |
| if (tick.interval) { | |
| tick.stop(); | |
| } else { | |
| onTick(); | |
| tick.start(TICK_RATE); | |
| } | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment