diff --git a/articles/auto-thefts-2022/auto-thefts.js b/articles/auto-thefts-2022/auto-thefts.js index 156a234..713fb40 100644 --- a/articles/auto-thefts-2022/auto-thefts.js +++ b/articles/auto-thefts-2022/auto-thefts.js @@ -1,3 +1,4 @@ +// establish array of location types const locations = [ "Streets, Roads, Highways (Bicycle Path, Private Road)", "Parking Lots (Apt., Commercial Or Non-Commercial)", @@ -38,6 +39,7 @@ const locations = [ "Community Group Home", ]; +// show a notification when the year is changed (on mobile) let yearTimer; function updateDataLabel(year) { if (["", "sm", "md"].includes(module.currentBreakpoint())) { @@ -65,12 +67,14 @@ function updateDataLabel(year) { } } +// initialize a geojson object to hold the data const mappable = { type: "FeatureCollection", features: [], }; const noGood = []; +// collect data for the current selected year function filterData(year) { const thefts = mappable.features.filter((f) => f.properties.y == year); return { @@ -79,12 +83,14 @@ function filterData(year) { }; } +// update the map with the selected year's data function updateViz(year) { const src = module.map.getSource("auto-theft"); src.setData(filterData(year)); updateDataLabel(year); } +// build the year selector element const radioYears = document.createElement("div"); radioYears.innerHTML = `
@@ -113,6 +119,8 @@ radioYears.innerHTML = `
`; + +// build and initialize the legend function addLegend() { module.setLegendTitle("Toronto Auto Thefts"); module.addToLegend(radioYears); @@ -152,6 +160,7 @@ function addLegend() { selector.addEventListener("change", (e) => updateViz(e.target.value)); } +// show a popup for a theft location function showPopup(e) { module.clearPopups(); const { l, p, n } = e.features[0].properties; @@ -219,6 +228,7 @@ function showPopup(e) { }); } +// establish a consistent opacity const opacity = [ "interpolate", ["linear"], @@ -234,6 +244,7 @@ const opacity = [ ]; function displayData() { + // adds point clusters at higher zoom levels module.addVizLayer({ id: "clusters", filter: ["has", "point_count"], @@ -326,6 +337,7 @@ function displayData() { fetch(url) .then((r) => r.json()) .then((d) => { + // remove data points outside of Toronto (mislabelled) d.features.forEach((f) => { if ( f.geometry.coordinates[0] < -79.9 || @@ -345,6 +357,7 @@ fetch(url) data: mappable, type: "geojson", }); + // load and display data for 2022 updateViz(2022); displayData(); }) diff --git a/articles/auto-thefts-2022/neighbourhoods.js b/articles/auto-thefts-2022/neighbourhoods.js index aec4b18..3cd9bb0 100644 --- a/articles/auto-thefts-2022/neighbourhoods.js +++ b/articles/auto-thefts-2022/neighbourhoods.js @@ -1,9 +1,11 @@ +// hide selected mapbox layers to reduce visual clutter module.hideLayer("settlement-subdivision-label"); module.hideLayer("settlement-minor-label"); module.hideLayer("settlement-major-label"); let currentYear = 2022; +// update the visualisation based on the selected year function updateViz(year) { module.clearPopups(); currentYear = year; @@ -39,6 +41,7 @@ function updateViz(year) { ]); } +// listen for the initial year selector to be added to the DOM and then add an event listener const interval = setInterval(() => { const selector = document.getElementById("year-selector"); if (selector) { @@ -47,6 +50,7 @@ const interval = setInterval(() => { } }, 1000); +// establish consistent text colours const textColour = { red: "text-red-700 dark:text-red-500", orange: "text-orange-700 dark:text-orange-500", @@ -54,6 +58,7 @@ const textColour = { blue: "text-blue-700 dark:text-blue-500", }; +// show the popup with the neighbourhood details function showDetails(e) { if (module.map.getLayer("clusters")) { const clusters = module.map.queryRenderedFeatures(e.point, { @@ -110,6 +115,7 @@ function showDetails(e) { .setLngLat(center.geometry.coordinates) .setHTML(defaultHTML), ); + // zoom to the neighbourhood module.map.fitBounds(bbox, { bearing: module.map.getBearing(), duration: 2500, @@ -119,11 +125,13 @@ function showDetails(e) { }); } +// initialise an object to store upper limits const limits = { highestTotal: 0, highestPerKm: 0, }; +// build the neighbourhood visual and add it to the map let hoveredStateId = null; function addNeighbourhoods() { module.addUnderglowLayer({ @@ -207,7 +215,9 @@ function addNeighbourhoods() { ], }, }); + // show a popup on click module.handleCursor("neighbourhoods-trigger", showDetails); + // highlight the neighbourhood boundary on hover module.map.on("mousemove", "neighbourhoods-trigger", (e) => { if (e.features.length > 0) { if (hoveredStateId !== null) { @@ -234,6 +244,7 @@ function addNeighbourhoods() { }); } +// get the upper limits for the data function getLimits(features) { features.forEach((f) => { [2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022].forEach((year) => { @@ -247,6 +258,7 @@ function getLimits(features) { }); } +// fetch the neighbourhood data and begin building the visualisation fetch(url) .then((r) => r.json()) .then((d) => { diff --git a/articles/auto-thefts-2022/readme.md b/articles/auto-thefts-2022/readme.md new file mode 100644 index 0000000..6dd7ab7 --- /dev/null +++ b/articles/auto-thefts-2022/readme.md @@ -0,0 +1,7 @@ +# We mapped Toronto auto thefts. Here are the hardest-hit areas. + +** Code and markup by Kyle Duncan** + +This is a data analysis and visualisation based on auto theft data from the Toronto Police Service. There are three aspects to this visualisation, an elevation-based overview of total thefts by neighbourhood, and a colour-based visual of thefts per square kilometer by neighbourhood at lower zoom levels; and a clustered count of individual thefts locations at higher zoom levels. + +Popups with more detail appear on click for both neighbourhoods and individual thefts, and the user can view a given year's data using the selector in the legend. Multiple thefts at a single location are collated and ordered in single popup. diff --git a/articles/greater-toronto-area-quiz/article.html b/articles/greater-toronto-area-quiz/article.html index 91de3e3..9a4f3a7 100644 --- a/articles/greater-toronto-area-quiz/article.html +++ b/articles/greater-toronto-area-quiz/article.html @@ -1 +1,28 @@ +
+ A view of highway signs in the Greater Toronto Area +
+ No hints. (Photo illustration by Torontoverse Staff; + Wikimedia Commons) +
+
+ +

+ You’ve come to a site called Torontoverse, so you must have at least + some knowledge of the place. Or you came by mistake. Whatever. +

+

+ The point is: The Greater Toronto and Hamilton Area is made up of 26 distinct + places — a mix of urban, suburban, and rural municipalities. How many of them + can you pick out on a map? +

diff --git a/articles/greater-toronto-area-quiz/article.js b/articles/greater-toronto-area-quiz/article.js index ffc3076..7da13ae 100644 --- a/articles/greater-toronto-area-quiz/article.js +++ b/articles/greater-toronto-area-quiz/article.js @@ -1,3 +1,4 @@ +// hide the article window function goFullScreen() { const fullscreen = document.querySelector(".mapctrl-fullscreen"); if (fullscreen) fullscreen.click(); @@ -5,6 +6,7 @@ function goFullScreen() { } goFullScreen(); +// show a popup for the intro or instructions function showPopup(content) { module.clearPopups(); module.showPopup( @@ -19,6 +21,7 @@ function showPopup(content) { ); } +// initialize data to be used throughout the quiz const quiz = { // establish objects for data answerList: [], @@ -42,6 +45,7 @@ const quiz = { }, }; +// initialize comments for responses and end of quiz quiz.comments = { result: { best: "You’re the Greater-est. Drake should write a song about you.", // 21–25 correct @@ -73,12 +77,13 @@ quiz.comments = { ], }; +// return a random index of an array quiz.getRandom = (array) => { const randomIndex = Math.floor(Math.random() * array.length); return array[randomIndex]; }; -// show an intro popup +// show the intro popup quiz.showIntro = () => { const container = document.createElement("div"); const content = document.createElement("div"); @@ -146,16 +151,14 @@ quiz.showInstructions = () => { .addEventListener("click", quiz.showPrompt); }; +// create a persistent element at the bottom center of the map that shows: +// - the next municipality to guess +// - the time remaining +// - the number of correct answers +// - a button to skip to the next municipality +// - a button to end the quiz quiz.showPrompt = () => { module.clearPopups(); - /* - create a persistent element at the bottom center of the map that shows: - - the next municipality to guess - - the time remaining - - the number of correct answers - - a button to skip to the next municipality - - a button to end the quiz - */ const prompt = document.createElement("div"); prompt.className = "absolute bg-map-100 dark:bg-map-800 bottom-14 lg:bottom-10 cursor-default default-popoup flex flex-col inset-x-2.5 md:inset-x-1/4 lg:inset-x-1/3 items-center justify-center"; @@ -180,12 +183,11 @@ quiz.showPrompt = () => {
- - + +
`; prompt.appendChild(content); - // add hover states to buttons module.map.getContainer().appendChild(prompt); window.addEventListener("flexWindowReset", () => { document.getElementById("promptPopup").remove(); @@ -198,10 +200,9 @@ quiz.newPrompt = () => { quiz.prompt.innerText = quiz.currentPrompt; }; +/* INITIALIZE QUIZ */ quiz.start = () => { - /* INITIALIZE QUIZ */ // grab visual elements - // text quiz.siienComment = document.getElementById("commentText"); quiz.prompt = document.getElementById("prompt"); @@ -215,10 +216,11 @@ quiz.start = () => { // buttons quiz.endButton = document.getElementById("giveUp"); quiz.skipButton = document.getElementById("skipPrompt"); - + // listen for responses and other elements module.handleCursor("boundary-fills", quiz.checkAnswer); quiz.skipButton.addEventListener("click", quiz.newPrompt); quiz.endButton.addEventListener("click", quiz.giveUp); + // load and start the quiz quiz.buildData(); quiz.newPrompt(); quiz.startTimer(); @@ -301,7 +303,6 @@ quiz.correctGuess = (answer) => { if (quiz.correctAnswers.length === quiz.masterTotals.answers) { setTimeout(() => quiz.end("win"), 1000); } else quiz.newPrompt(); - // else quiz.animateSiien("bounce"); }; quiz.revealAnswer = (answer) => { @@ -341,6 +342,7 @@ quiz.timeLeftPercentage = ({ minutes, seconds }) => { return percentage; }; +// make sure the shorter bar is on top of the other quiz.checkBarZindex = () => { const scoreWidth = +quiz.scoreBar.style.width.slice(0, -1); const timeWidth = +quiz.timeBar.style.width.slice(0, -1); @@ -532,8 +534,6 @@ quiz.reset = () => { module.map.once("idle", quiz.showInstructions); }; -// add intro content to legend/article body? - // add a listener to the map that shows the intro popup when the map is idle module.map.once("idle", () => { setTimeout(quiz.showIntro, 500); diff --git a/articles/greater-toronto-area-quiz/readme.md b/articles/greater-toronto-area-quiz/readme.md new file mode 100644 index 0000000..d0d812b --- /dev/null +++ b/articles/greater-toronto-area-quiz/readme.md @@ -0,0 +1,5 @@ +# The great Greater Toronto and Hamilton Area municipality quiz + +**Code and markup by Kyle Duncan** + +This "article" was an experiment to try different kinds of user interaction as well as a full screen mode, i.e. no article text. At present this is achieved by manually hiding the article window on load. Initially the guesses were typed into a form element, but in adapting to mobile this was converted to a prompt of a municipality name which the user than has to correctly click or tap. diff --git a/articles/raptors-distance-traveled/article.js b/articles/raptors-distance-traveled/article.js index a617a02..1f51da7 100644 --- a/articles/raptors-distance-traveled/article.js +++ b/articles/raptors-distance-traveled/article.js @@ -1,21 +1,13 @@ -// arena center +// arena center coordinates const arena = { lng: -79.3790169712448, lat: 43.64344921310201, }; +// initialize object to store routes const routes = {}; -function addRoutePoint(id) { - const next = routes[id].coordinates.shift(); - routes[id].geometry.coordinates.push(next); - module.map.getSource(`${id}-symbol`).setData({ - type: "Point", - coordinates: next, - }); - return routes[id].geometry; -} - +// listen for article close to abandon animation let abort = false; window.addEventListener( "flexWindowReset", @@ -31,6 +23,18 @@ window.addEventListener( { once: true }, ); +// add new route point data and update map +function addRoutePoint(id) { + const next = routes[id].coordinates.shift(); + routes[id].geometry.coordinates.push(next); + module.map.getSource(`${id}-symbol`).setData({ + type: "Point", + coordinates: next, + }); + return routes[id].geometry; +} + +// animate route by adding new point every frame function animateRoute(id) { if (routes[id].coordinates.length > 0 && !abort) { module.map.getSource(id).setData(addRoutePoint(id)); @@ -38,6 +42,7 @@ function animateRoute(id) { } } +// Create and show player popup function showPopup(e) { const id = e.features[0].layer.id.slice(0, -7); const { destination, kilometers, player } = routes[id]; @@ -65,6 +70,7 @@ function showPopup(e) { } } +// build player routes and add to map for animation function mapRoutes() { for (const player in routes) { const { geometry, image } = routes[player]; @@ -126,6 +132,7 @@ function mapRoutes() { } } +// caclucates and zooms to a bounding box from all route points const bboxRaw = [[arena.lng, arena.lat]]; function zoomToBbox() { let minLng, minLat, maxLng, maxLat; @@ -157,6 +164,7 @@ function zoomToBbox() { module.map.once("idle", mapRoutes); } +// match players distances to a POI and store in routes object const destinations = []; function findMatches(distances) { destinations.sort((a, b) => { @@ -197,6 +205,7 @@ function findMatches(distances) { setTimeout(zoomToBbox, 1000); } +// load available player images function addImages(data) { data.forEach((player) => { if (player.image && !module.map.hasImage(player.image)) { @@ -211,6 +220,7 @@ function addImages(data) { }); } +// get updated distances data function getDistances() { fetch( "https://media.geomodul.us/articles/raptors-distance-travelled/distances.json", @@ -223,6 +233,7 @@ function getDistances() { .catch((e) => console.log("error:", e)); } +// add the arena graphic to the map function addArena() { // add arena location as source module.addSource("scotiabank-arena", { @@ -279,6 +290,7 @@ function addArena() { }); } +// load arena graphic if (!module.map.hasImage("scotiabank-arena")) { module.map.loadImage( "https://media.geomodul.us/img/scotiabank-arena-md.png", @@ -290,6 +302,7 @@ if (!module.map.hasImage("scotiabank-arena")) { ); } else addArena(); +// load basketball graphic for missing player images if (!module.map.hasImage("orange-bball")) { module.map.loadImage( "https://media.geomodul.us/img/raptor-heads/basketball-fill.png", @@ -300,6 +313,7 @@ if (!module.map.hasImage("orange-bball")) { ); } +// load route data fetch( "https://media.geomodul.us/articles/raptors-distance-travelled/poi-routes.json", ) diff --git a/articles/raptors-distance-traveled/readme.md b/articles/raptors-distance-traveled/readme.md index a3383fa..77fdd89 100644 --- a/articles/raptors-distance-traveled/readme.md +++ b/articles/raptors-distance-traveled/readme.md @@ -1,4 +1,14 @@ -# How to update this article +# How far did each Raptor actually travel versus the Rockets? + +**Code and markup by Kyle Duncan** + +This article takes a set of distances travelled by Raptors players in a particular NBA game, finds an exisitng 'Place of Interest' (POI) on the Torontoverse map, and then animates images of each player along a route from the Raptor's arena to that location comparable to the distance travelled. + +The article was designed to be updated for different games using the public [stats from the NBA](https://www.nba.com/stats/players/speed-distance?TeamID=1610612761&LastNGames=1&Location=Home) ("Dist. Miles" by player). + +The routes and distance to our POI collection were calculated and stored in a static file using the [mapbox directions API](https://docs.mapbox.com/api/navigation/directions/), being manually updated as new POI were added. + +## How to update the game/distances 1. Get distances from [NBA stats](https://www.nba.com/stats/players/speed-distance?TeamID=1610612761&LastNGames=1&Location=Home) ("Dist. Miles" by player) @@ -10,12 +20,12 @@ --- -# How to update POI routes +## How to update POI routes 1. Run the following code: ``` -const token = "pk.eyJ1IjoiZ2VvbW9kdWx1cyIsImEiOiJja2hubHI2eG0wMW1jMnJxcTk1OWVycjF1In0.kKg7JGdOkhKk_tiS-Wh-Rw"; +const token = "your-mapbox-token-here"; const destinations = []; @@ -57,3 +67,7 @@ setTimeout(() => { 3. Update the "last updated" field below. > Last Updated: 9 November 2022 + +--- + +_Original article written by Craig Battle_ diff --git a/articles/yonge-subway-north-extension/readme.md b/articles/yonge-subway-north-extension/readme.md index 909dc7a..3fb4bf9 100644 --- a/articles/yonge-subway-north-extension/readme.md +++ b/articles/yonge-subway-north-extension/readme.md @@ -1,8 +1,11 @@ # North to Richmond Hill: Breaking down the 8-km Yonge subway extension **Code and markup by Kyle Duncan** + This article uses Torontoverse's `SceneList` module to direct the user to particular elements of the map visualisation at key points in the article scroll. + It fetches geojson data for the existing and proposed sections of TTC Line 1, and also for two connected major bus and train lines, GO and Viva BRT, and adds those to the map. It programatically builds a legend, identifying the different lines, stops, transit hubs or portals, and grade levels of the proposed extension. + Finally, the particular scenes are created and connected to elements in the article that will be triggered using the [Scroll Magic](http://scrollmagic.io/) plugin. ---