├── .artifacts.yml ├── .gitignore ├── .publisher.yml ├── LICENSE.md ├── README.md ├── TODO.md ├── assets ├── data-join-detail.svg ├── data-tiling.svg ├── election-participation.gif └── joining-real-time-data.svg ├── client ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.tsx │ ├── MapFeatures.ts │ ├── RealtimeMap.css │ ├── RealtimeMap.tsx │ ├── index.css │ ├── index.js │ ├── logo.svg │ ├── react-app-env.d.ts │ ├── transformRequest.ts │ ├── types │ │ └── turf.center.d.ts │ └── useStreamingElectionData.ts └── tsconfig.json ├── data ├── .gitignore ├── README.md ├── counties.ndjson ├── package-lock.json ├── package.json ├── recipe.json ├── uploadData.js └── votes_animation_sequence.json ├── package-lock.json ├── package.json ├── sample.env └── server ├── README.md ├── availability.js ├── availability.test.js ├── index.js ├── mockElectionReturns.js ├── package-lock.json ├── package.json ├── precinct.js ├── precinct.test.js ├── remap.js └── remap.test.js /.artifacts.yml: -------------------------------------------------------------------------------- 1 | version: v1 2 | publisher: 3 | builds: 4 | - filter: on-branch 5 | name: publisher-branches 6 | config: 7 | node_version: 12.x 8 | npm_build_script: build 9 | site_build_dir: build 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | .env 3 | 4 | # Logs 5 | .DS_Store 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | 24 | # nyc test coverage 25 | .nyc_output 26 | 27 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 28 | .grunt 29 | 30 | # Bower dependency directory (https://bower.io/) 31 | bower_components 32 | 33 | # node-waf configuration 34 | .lock-wscript 35 | 36 | # Compiled binary addons (https://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directories 40 | node_modules/ 41 | jspm_packages/ 42 | 43 | # TypeScript v1 declaration files 44 | typings/ 45 | 46 | # Optional npm cache directory 47 | .npm 48 | 49 | # Optional eslint cache 50 | .eslintcache 51 | 52 | # Optional REPL history 53 | .node_repl_history 54 | 55 | # Output of 'npm pack' 56 | *.tgz 57 | 58 | # Yarn Integrity file 59 | .yarn-integrity 60 | 61 | # next.js build output 62 | .next 63 | -------------------------------------------------------------------------------- /.publisher.yml: -------------------------------------------------------------------------------- 1 | subdomain: demos 2 | base_path: blueprints/realtime-pois 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Mapbox 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | * Neither the name of Mapbox GL JS nor the names of its contributors 14 | may be used to endorse or promote products derived from this software 15 | without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR 21 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, 22 | EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 23 | PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR 24 | PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF 25 | LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING 26 | NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Real-time mapping 2 | 3 | This is a reference architecture for visualizing real-time data with Mapbox. It implements and explains the solution described on the [Real-time Mapping](https://www.mapbox.com/solutions/real-time-maps) page. 4 | 5 | It includes an election-based example where counties are updated live with voter participation data sent from a server. The data for this example is based on historic election participation and is animated using simulated poll-closing times. 6 | 7 | 8 | 9 | - [Quick start](#quick-start) 10 | - [Development](#development) 11 | * [Configuration](#configuration) 12 | * [Directory structure](#directory-structure) 13 | * [Data architecture](#data-architecture) 14 | * [Example process](#example-process) 15 | + [Upload the sample data to your own Mapbox account](#upload-the-sample-data-to-your-own-mapbox-account) 16 | + [Modify the sample to use your own data](#modify-the-sample-to-use-your-own-data) 17 | - [1) Create a tileset](#1-create-a-tileset) 18 | - [2) Use the tileset in a map style](#2-use-the-tileset-in-a-map-style) 19 | - [3) Use the style in the demo](#3-use-the-style-in-the-demo) 20 | - [Using a custom front-end](#using-a-custom-front-end) 21 | - [Deployment](#deployment) 22 | - [Built with](#built-with) 23 | * [Mapbox APIs](#mapbox-apis) 24 | - [Authors](#authors) 25 | - [License](#license) 26 | - [Acknowledgments](#acknowledgments) 27 | 28 | 29 | 30 | ## Quick start 31 | 32 | Add a valid [Mapbox access token](https://account.mapbox.com/access-tokens/) to your environment. Tokens can be added via your shell: 33 | 34 | ```shell 35 | export REACT_APP_MAPBOX_TOKEN= 36 | ``` 37 | 38 | Tokens can also be added to a `.env` file. See [`sample.env`](./sample.env) for an example of how to structure your `.env` file. 39 | 40 | Once the environment is configured, install dependencies and start the application. 41 | 42 | ```shell 43 | npm install 44 | npm start 45 | ``` 46 | 47 | Your browser will open a page displaying a map of US counties. Over time, the counties change color as the server reports the number of votes cast in each county. 48 | 49 | ![animation depicting voter participation rates in US counties as mock results roll in across the country](./assets/election-participation.gif) 50 | 51 | ## Development 52 | 53 | ### Configuration 54 | 55 | You need a recent version of [Node.js](https://nodejs.org/en/). This architecture was developed with Node 12.8. 56 | 57 | You need an active [Mapbox account](https://account.mapbox.com/auth/signup/) and [access token](https://account.mapbox.com/access-tokens/). 58 | 59 | ### Directory structure 60 | 61 | The code for this project is organized in three top-level directories. 62 | 63 | ``` 64 | client/ -- web front-end that joins tiled geometry and real-time data from the server 65 | server/ -- SSE server that emits mock voter turnout data over time 66 | data/ -- script for data upload, county boundary data, and election participation data 67 | ``` 68 | 69 | ### Data architecture 70 | 71 | Two sources of data need to be joined at runtime for the real-time visualization to work. One is the source geometry, which is served as tiles from Mapbox. The other is a sequence of real-time messages. 72 | 73 | ![diagram showing server providing live data to a stack of map layers based on ids retrieved from the top map layer](./assets/joining-real-time-data.svg) 74 | 75 | The source geometry goes through a series of transformations before being tiled. We first make sure that it has the attributes we care about and then format it as a GeoJSON sequence. Once uploaded, the Tilesets API lets us further filter the data and limit what is served in our tiles to only what our application needs. 76 | 77 | ![sequential diagram displaying source data, conversion to newline-delimited GeoJSON, using the tilesets API to upload a source and recipe, and publishing a tileset for use in Studio or a gl-js map](./assets/data-tiling.svg) 78 | 79 | At runtime, the client joins tiled geometry to live data streamed from a server and styles it based on their real-time values. 80 | 81 | In order to join the two sources of data, the geometry needs a property that matches the real-time data from the server. For this application, we store the county FIPS code as the feature ID to use for runtime joining. 82 | 83 | The data join happens by [setting the map's feature-state](./client/src/RealtimeMap.tsx#L99) whenever new data is received from the server. 84 | 85 | ```javascript 86 | map.once("style.load", () => { 87 | subscription = electionData.subscribe(update => { 88 | if (update === RESET) { 89 | map.removeFeatureState({ source: "composite", sourceLayer: realtimeLayerID }); 90 | } else { 91 | update.forEach(county => { 92 | const voteProportion = county.votes_total / county.population; 93 | if (county.geoid === "NA") { 94 | return; 95 | } 96 | // Assign the `voteProportion` feature-state to the source feature 97 | // whose ID matches the county's geoid 98 | map.setFeatureState( 99 | { source: "composite", sourceLayer: realtimeLayerID, id: county.geoid }, 100 | { voteProportion } 101 | ); 102 | }); 103 | } 104 | }); 105 | ``` 106 | 107 | An [expression in the map's style object](./client/src/RealtimeMap.tsx#L57) determines how the feature-state is interpreted as a visual on the map. 108 | 109 | ```javascript 110 | map.setPaintProperty(realtimeLayerID, "fill-color", [ 111 | "case", 112 | ["!=", ["feature-state", "voteProportion"], null], 113 | // if we have turnout information for a feature, use it to interpolate a color 114 | [ 115 | "interpolate", 116 | ["exponential", 2], 117 | // use the value of the `voteProportion` feature-state as an input 118 | ["feature-state", "voteProportion"], 119 | // color low turnout purple 120 | 0.3, 121 | "rgba(127, 0, 200, 0.6)", 122 | // color high turnout bright green 123 | 0.7, 124 | "rgba(0, 255, 80, 0.9)" 125 | ], 126 | // if there is no turnout information, use gray 127 | "rgba(127, 127, 127, 0.5)" 128 | ]); 129 | ``` 130 | 131 | Because the state and the style work in conjunction, the visual map updates in real-time whenever new data is received from the server and assigned to a value in feature-state. 132 | 133 | ![Diagram displaying a matching ID in tiled geometry and a real-time message being used by a renderer to style the geometry based on the value of the real-time message](./assets/data-join-detail.svg) 134 | 135 | ### Example process 136 | 137 | #### Upload the sample data to your own Mapbox account 138 | 139 | You can upload the US county geometry and use it within your own account by following the steps outlined in the data [README](./data/README.md). 140 | 141 | A typical geometry feature follows. It has many properties, like `STATE_NAME`, that we can use in our tilesets recipe for deciding when and how to include data in our tileset. We use the `GEOID` as the feature id in our tileset, which lets us connect the tiled data to our real-time information about each county. 142 | 143 | ``` 144 | {"type": "Feature", "properties": {"STATEFP": "21", "COUNTYFP": "007", "COUNTYNS": "00516850", "AFFGEOID": "0500000US21007", "GEOID": "21007", "NAME": "Ballard", "LSAD": "06", "ALAND": 639387454, "AWATER": 69473325, "STATE_NAME": "Kentucky"}, "geometry": {"type": "Polygon", "coordinates": [...]}} 145 | ``` 146 | 147 | #### Modify the sample to use your own data 148 | 149 | To tile your own geometry for real-time data joining, do the following: 150 | 151 | ##### 1) Create a tileset 152 | 153 | Use the Tilesets API to upload newline-delimited GeoJSON and publish a tileset. 154 | 155 | ##### 2) Use the tileset in a map style 156 | 157 | Define a new map style in Mapbox Studio and add the tileset as a source for a layer. Style it as desired for visibility and size across relevant zoom levels. The default appearance should be appropriate for a "no data available" state. 158 | 159 | ##### 3) Use the style in the demo 160 | 161 | Pass your [style URL](https://docs.mapbox.com/help/glossary/style-url/) as a prop to the RealtimeMap to test out your data. If it uses 2018 FIPS codes for the data join, it should work with the existing application. 162 | 163 | ## Using a custom front-end 164 | 165 | To use a custom front-end, make sure you are loading a style with relevant source ids. Then configure the visuals and state updates based on your applications needs. The core tasks are adding a paint property that is controlled by feature state, subscribing to updates or polling for data about relevant features, and setting feature state on the map as real-time updates arrive. 166 | 167 | ## Deployment 168 | 169 | For deployment, you will need access to a live data provider. 170 | 171 | Map styles created with the Tilesets API and Mapbox Studio are production ready. 172 | 173 | ## Built with 174 | 175 | ### Mapbox APIs 176 | 177 | - [Mapbox Studio](https://docs.mapbox.com/studio-manual/overview/) 178 | - Data previsualization 179 | - Map styling 180 | - [Tilesets API](https://docs.mapbox.com/api/maps/#tilesets) 181 | - Data upload 182 | - Data filtering 183 | - Data tiling 184 | - [Mapbox GL JS](https://docs.mapbox.com/mapbox-gl-js/api/) 185 | - Map rendering 186 | - Runtime data-join 187 | 188 | ### Third-party libraries 189 | 190 | - [RXJS](https://rxjs.dev/) 191 | - [React.js](https://reactjs.org/) 192 | - [Express.js](https://expressjs.com/) 193 | 194 | ## Authors 195 | 196 | This project was created by the Mapbox Solutions Architecture team. 197 | solutions_architecture@mapbox.com 198 | 199 | ## License 200 | 201 | The code for this project is licensed under the BSD 3-Clause License - see the [LICENSE](./LICENSE.md) file for details. 202 | 203 | For data licensing, see the [README in the data/ folder](./data/README.md). 204 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | ## TO-DO list 2 | 3 | Once this repository has been created, do the following: 4 | 5 | 1. Checklist Items 6 | 7 | - [ ] Update your Readme 8 | - [ ] Check your Dotfiles (`.npmignore`, `.eslintignore`,`.dockerignore`) 9 | - [ ] Make sure all the correct information is in your `package.json` if necessary 10 | - [ ] Make sure all keywords and such are in the `package.json` 11 | 12 | 2. Create the following issues against your `LAUNCH` milestone: 13 | 14 | - [ ] Confirm license choice 15 | - [ ] Clean repo with no commit history 16 | - [ ] README Review 17 | - [ ] Security Review 18 | - [ ] Guru card completed 19 | - [ ] Internal Blog post PR - clone the [SA repo](https://github.com/mapbox/solutions-architecture) and run `yarn new` to generate post 20 | - [ ] Landing Page PR - use the template [here](https://paper.dropbox.com/folder/show/Completed-Content-Templates-e.1gg8YzoPEhbTkrhvQwJ2zzy5XrrKwjWrH2CJmeTcuieE6A3ppkFl) for writing 21 | - [ ] Announcement Blog PR and scheduled with Marketing 22 | - [ ] [Sign up for a Webinar](https://docs.google.com/spreadsheets/d/1x7muk4VlMd7co31jwha77kNSsYpxcz83AwA99ES3f6M/edit?usp=sharing) about your blueprint 23 | -------------------------------------------------------------------------------- /assets/data-tiling.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Data must be a sequence of newline-delimited GeoJSON objects for upload through the Tilesets API. All data attributes are retained when uploaded to the server. 4 | data.ndjson 5 | ogr2ogr is a command-line tool distributed as part of the GDAL package and converts data from one geographic representation to another. We use it to convert our source geometry to an array of GeoJSON objects. 6 | ogr2ogr -f “GeoJSONSeq” source data.ndjson 7 | mapbox://tileset-source/mbxsolutions/us-counties 8 | Uploaded source files are hosted by Mapbox and can be shared by multiple recipes. 9 | The recipe defines how to interpret the source data when tiling. For the elections sample, we filter the US counties to only include counties from the lower 48 states and DC. We also limit the published attributes to the ones used in our application and ensure that the each feature’s ID is derived from its county FIPS code. 10 | recipe.json 11 | Most formats can be converted to GeoJSONSeq by ogr2ogr. The Census Bureau provides county and other administrative boundaries in shapefile format. 12 | cb_2018_us_county_500k.shp 13 | Source Data 14 | ogr2ogr 15 | GeoJSONSeq 16 | Tilesets Source 17 | Published Tileset 18 | Tilesets Recipe 19 | 20 | 21 | This project uses a Node script to interact with the Tilesets API for uploading source data, a recipe, and publishing its tileset. A Python-based CLI provides more complete interaction with the Tilesets API. 22 | npm run upload-data 23 | Tilesets API 24 | 25 | 26 | 27 | 28 | The published tileset can be added to a map style in Studio or added as a new source in gl-js. 29 | mbxsolutions.us-counties 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /assets/election-participation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/real-time-maps/7612cdcac6a56bc54e9be6be52a0b0ac71495a17/assets/election-participation.gif -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Voter Participation 2 | 3 | This package contains an example frontend for displaying real-time voting data using React and Mapbox GL JS. 4 | 5 | At runtime, this client joins tiled county geometry to live voter participation data streamed from a server. The counties are styled based on their real-time voter participation value. 6 | 7 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 8 | 9 | ## Quick Start 10 | 11 | The client is run by the root package's `npm run start` script, which also starts the API server. If you run it without the API server, you will see an unchanging gray map. 12 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-mapping-client", 3 | "version": "1.0.0", 4 | "author": "David Wicks (https://sansumbrella.com/)", 5 | "license": "BSD-3-Clause", 6 | "start_url": ".", 7 | "homepage": "https://demos.mapbox.com/blueprints/realtime-pois/", 8 | "dependencies": { 9 | "@turf/center": "^6.0.1", 10 | "@types/jest": "^24.9.1", 11 | "@types/mapbox-gl": "^0.54.5", 12 | "@types/node": "^12.12.53", 13 | "@types/react": "^16.9.43", 14 | "@types/react-dom": "^16.9.8", 15 | "mapbox-gl": "^1.11.1", 16 | "react": "^16.13.1", 17 | "react-dom": "^16.13.1", 18 | "react-scripts": "^3.4.1", 19 | "rxjs": "^6.6.0", 20 | "typescript": "^3.9.7", 21 | "use-mapbox": "^0.2.3" 22 | }, 23 | "scripts": { 24 | "start": "PORT=3000 react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/real-time-maps/7612cdcac6a56bc54e9be6be52a0b0ac71495a17/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Real-time mapping with Mapbox 25 | 26 | 27 | 28 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: /static/ -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | flex-direction: row; 4 | } 5 | 6 | .column { 7 | display: flex; 8 | flex-direction: column; 9 | } 10 | 11 | .margin-1 { 12 | margin: 1rem; 13 | } 14 | 15 | .width-50 { 16 | max-width: 50vw; 17 | } 18 | 19 | p { 20 | font-size: 1.5em; 21 | } 22 | 23 | p, 24 | h1, 25 | h2 { 26 | margin: 0.5rem 0; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { RealtimeMap } from "./RealtimeMap"; 3 | import "./App.css"; 4 | import { useStreamingElectionData, RESET, ElectionSubject } from "./useStreamingElectionData"; 5 | 6 | /** 7 | * Parent component containing a real-time election map. 8 | * Subscribes to an observable data stream and shares that 9 | * stream with the map to enable real-time visualization. 10 | */ 11 | function App() { 12 | // Connect to local server's election stream for real-time data 13 | const electionData = useStreamingElectionData("//localhost:5000/election-stream"); 14 | const { countiesReporting, votesCast } = useAggregateStatistics(electionData); 15 | return ( 16 |
17 | 22 |
23 |
24 |

Real-time election map simulation

25 |

Counties reporting: {countiesReporting.toLocaleString()}

26 |

Votes counted: {votesCast.toLocaleString()}

27 |

28 | This application shows voter turnout rates across the contiguous United States during the 2016 general 29 | election. The map is updated as voting data is sent from the server. For this demo, the server simulates 30 | polls closing at different times across the country. 31 |

32 |
33 |
34 |

35 | Read more about real-time mapping and the architecture of this application on the{" "} 36 | real-time mapping solutions page. 37 |

38 |
39 |
40 |
41 | ); 42 | } 43 | 44 | export default App; 45 | 46 | /** 47 | * Returns aggregate statistics from incoming election results. 48 | */ 49 | function useAggregateStatistics(electionData: ElectionSubject) { 50 | const [countiesReporting, setCountiesReporting] = useState(0); 51 | const [votesCast, setVotesCast] = useState(0); 52 | useEffect( 53 | function countResponses() { 54 | const subscription = electionData.subscribe(update => { 55 | if (update === RESET) { 56 | setCountiesReporting(0); 57 | setVotesCast(0); 58 | } else { 59 | // accumulate number of counties reporting 60 | setCountiesReporting(count => count + update.length); 61 | // accumulate total votes cast 62 | const newVotesRecorded = update.reduce((prev, current) => { 63 | return prev + current.votes_total; 64 | }, 0); 65 | setVotesCast(count => count + newVotesRecorded); 66 | } 67 | }); 68 | return () => { 69 | subscription.unsubscribe(); 70 | }; 71 | }, 72 | [electionData] 73 | ); 74 | return { countiesReporting, votesCast }; 75 | } 76 | -------------------------------------------------------------------------------- /client/src/MapFeatures.ts: -------------------------------------------------------------------------------- 1 | export type Feature = { 2 | id: string; 3 | precinct: string; 4 | status: number | null; 5 | }; 6 | 7 | export type FeatureSet = { [key: string]: Feature }; 8 | -------------------------------------------------------------------------------- /client/src/RealtimeMap.css: -------------------------------------------------------------------------------- 1 | .mapbox-map { 2 | min-height: 70vh; 3 | width: 100%; 4 | flex-grow: 2; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/RealtimeMap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState, Fragment } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import center from "@turf/center"; 4 | import mapboxgl from "mapbox-gl"; 5 | import { useMapbox } from "use-mapbox"; 6 | import { Subscription, Observable } from "rxjs"; 7 | import "mapbox-gl/dist/mapbox-gl.css"; 8 | import "./RealtimeMap.css"; 9 | import { transformRequest } from "./transformRequest"; 10 | import { ElectionUpdate, ResetCommand, RESET, ElectionSubject } from "./useStreamingElectionData"; 11 | 12 | interface Props { 13 | accessToken: string; 14 | styleUrl: string; 15 | electionData: ElectionSubject; 16 | } 17 | 18 | /** 19 | * React component rendering a real-time election map. 20 | * Subscribes to SSE messages streaming from server. 21 | * Displays a mapbox map with layer styles driven by real-time data. 22 | */ 23 | export function RealtimeMap({ accessToken, styleUrl, electionData }: Props) { 24 | const mapContainer = useRef(null); 25 | 26 | const map = useMapbox(mapContainer, accessToken, { 27 | transformRequest: transformRequest, 28 | style: styleUrl, 29 | center: [-96.5, 39.8], 30 | zoom: 3.8 31 | }); 32 | 33 | const displayLayerID = "county-shape"; // the display layer we dynamically style 34 | const sourceLayerID = "county"; // the source used by the display layer 35 | useElectionDataToStyleLayer(map, electionData, displayLayerID, sourceLayerID); 36 | useClickEffect(map, displayLayerID); 37 | 38 | return
; 39 | } 40 | 41 | /** 42 | * Set map's style to vary based on feature-state and subscribe 43 | * to election data messages to change the feature-state 44 | */ 45 | function useElectionDataToStyleLayer( 46 | map: mapboxgl.Map | undefined, 47 | electionData: Observable, 48 | displayLayerID: string, 49 | sourceLayerID: string 50 | ) { 51 | // Configure map style once to change when feature-state changes 52 | useEffect( 53 | function setStyleExpressions() { 54 | if (!map) { 55 | return; 56 | } 57 | map.once("style.load", () => { 58 | // Set paint properties of real-time data layer to vary 59 | // based on the state of each feature we retrieve from our live data 60 | map.setPaintProperty(displayLayerID, "fill-color", [ 61 | "case", 62 | ["!=", ["feature-state", "voteProportion"], null], 63 | // if we have turnout information for a feature, use it to interpolate a color 64 | [ 65 | "interpolate", 66 | ["exponential", 2], 67 | // use the value of the `voteProportion` feature-state as an input 68 | ["feature-state", "voteProportion"], 69 | // color low turnout purple 70 | 0.3, 71 | "rgba(127, 0, 200, 0.6)", 72 | // color high turnout bright green 73 | 0.7, 74 | "rgba(0, 255, 80, 0.9)" 75 | ], 76 | // if there is no turnout information, use gray 77 | "rgba(127, 127, 127, 0.5)" 78 | ]); 79 | }); 80 | }, 81 | [map, displayLayerID] 82 | ); 83 | // Subscribe to changes in election data and update feature-state as changes arrive 84 | useEffect( 85 | function updateFeatureStateWithElectionData() { 86 | if (!map) { 87 | return; 88 | } 89 | let subscription: Subscription; 90 | map.once("style.load", () => { 91 | subscription = electionData.subscribe(update => { 92 | if (update === RESET) { 93 | map.removeFeatureState({ source: "composite", sourceLayer: sourceLayerID }); 94 | } else { 95 | update.forEach(county => { 96 | const voteProportion = county.votes_total / county.population; 97 | if (county.geoid === "NA") { 98 | return; 99 | } 100 | // Assign the `voteProportion` feature-state to the source feature 101 | // whose ID matches the county's geoid 102 | map.setFeatureState( 103 | { source: "composite", sourceLayer: sourceLayerID, id: +county.geoid }, 104 | { voteProportion, population: county.population, votesTotal: county.votes_total } 105 | ); 106 | }); 107 | } 108 | }); 109 | }); 110 | 111 | return () => { 112 | if (subscription) { 113 | subscription.unsubscribe(); 114 | } 115 | }; 116 | }, 117 | [map, electionData, sourceLayerID] 118 | ); 119 | } 120 | 121 | /** 122 | * Show more information about counties when they are selected. 123 | */ 124 | function useClickEffect(onMap: mapboxgl.Map | undefined, displayLayerID: string) { 125 | const [popup] = useState(() => new mapboxgl.Popup({ closeButton: true, closeOnClick: false })); 126 | useEffect( 127 | function show() { 128 | if (!onMap) { 129 | return; 130 | } 131 | const map = onMap; 132 | function showPopup(event: any) { 133 | const feature = event.features && event.features[0]; 134 | if (!feature) { 135 | return; 136 | } 137 | // Change the cursor style as a UI indicator. 138 | map.getCanvas().style.cursor = "pointer"; 139 | 140 | // Ensure that if the map is zoomed out such that multiple 141 | // copies of the feature are visible, the popup appears 142 | // over the copy being pointed to. 143 | const { coordinates } = center(feature).geometry; 144 | while (Math.abs(event.lngLat.lng - coordinates[0]) > 180) { 145 | coordinates[0] += event.lngLat.lng > coordinates[0] ? 360 : -360; 146 | } 147 | 148 | const name = feature.properties.name; 149 | const { population, votesTotal, voteProportion } = feature.state; 150 | 151 | // Populate the popup and set its coordinates 152 | // based on the feature found. 153 | const element = document.createElement("div"); 154 | ReactDOM.render( 155 | 156 |

{name} County

157 |
158 |
Population
159 |
{(population && population.toLocaleString()) || "unreported"}
160 |
Votes cast
161 |
{(votesTotal && votesTotal.toLocaleString()) || "unreported"}
162 |
Participation rate
163 |
{(voteProportion && (voteProportion * 100).toFixed(2) + "%") || "unreported"}
164 |
165 |
, 166 | element 167 | ); 168 | popup 169 | .setLngLat(coordinates) 170 | .setHTML(element.outerHTML) 171 | .addTo(map); 172 | } 173 | 174 | function hidePopup() { 175 | map.getCanvas().style.cursor = ""; 176 | popup.remove(); 177 | } 178 | map.on("click", displayLayerID, showPopup); 179 | 180 | return () => { 181 | map.off("click", displayLayerID, showPopup); 182 | hidePopup(); 183 | }; 184 | }, 185 | [onMap, popup, displayLayerID] 186 | ); 187 | } 188 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/src/transformRequest.ts: -------------------------------------------------------------------------------- 1 | /// Attribute tile requests to this solution. 2 | /// Passed to the mapboxgl.Map constructor. 3 | /// 4 | /// This lets us know whether people are finding this source code useful, 5 | /// and influences our decision of whether to open-source more code in the 6 | /// future. Please keep it in your project if you find this starter code helpful 7 | /// so we can continue to make and share sample projects. 8 | export const transformRequest = (url: string): { url: string } => { 9 | const hasQuery = url.indexOf("?") !== -1; 10 | const suffix = hasQuery ? "&pluginName=RealtimeMappingArchitecture" : "?pluginName=RealtimeMappingArchitecture"; 11 | return { url: url + suffix }; 12 | }; 13 | -------------------------------------------------------------------------------- /client/src/types/turf.center.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@turf/center'; 2 | -------------------------------------------------------------------------------- /client/src/useStreamingElectionData.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { ReplaySubject } from "rxjs"; 3 | 4 | export interface ElectionUpdate { 5 | geoid: string; 6 | population: number; 7 | votes_total: number; 8 | } 9 | 10 | export type ResetCommand = "RESET"; 11 | export const RESET: ResetCommand = "RESET"; 12 | export type ElectionMessage = ElectionUpdate[] | ResetCommand; 13 | export type ElectionSubject = ReplaySubject; 14 | 15 | /** 16 | * Create an observable stream of election data for use in React components. 17 | * The returned subject can be shared across many components, but this function 18 | * should only be called once (in the top-level component). 19 | */ 20 | export function useStreamingElectionData(url: string): ElectionSubject { 21 | // remember earlier updates to avoid race condition with map initialization 22 | const [subject] = useState(new ReplaySubject(5)); 23 | 24 | useEffect( 25 | function subscribeToElectionData() { 26 | const source = new EventSource(url); 27 | source.addEventListener("message", function(message) { 28 | const data = decodeURI(message.data); 29 | if (data === RESET) { 30 | subject.next(RESET); 31 | } else { 32 | const update = JSON.parse(data) as ElectionUpdate[]; 33 | subject.next(update); 34 | } 35 | }); 36 | 37 | return () => { 38 | console.log("closing event source"); 39 | source.close(); 40 | }; 41 | }, 42 | [subject, url] 43 | ); 44 | 45 | return subject; 46 | } 47 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /data/.gitignore: -------------------------------------------------------------------------------- 1 | map-matched-features.* 2 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # Data Preparation 2 | 3 | A basic script for uploading data to Mapbox. Read more about the [Tilesets API](https://docs.mapbox.com/api/maps/#creating-new-tilesets-with-the-tilesets-api) in the online documentation. 4 | 5 | ## Usage 6 | 7 | The [`uploadData.js`](./uploadData.js) script combines all the steps needed to create a working tileset. To use it, ensure you have configured your environment with a secret token that has tilesets scopes. This can be done by editing the [`sample.env`](../sample.env) file in the top-level project directory and saving it as `.env`. The top-level node scripts will copy the `.env` file into this directory. 8 | 9 | ``` 10 | MAPBOX_USERNAME=your-username 11 | MAPBOX_ACCESS_TOKEN=sk.your-secret-token-hash 12 | ``` 13 | 14 | To make a tileset with that script using default options and sample data, run the following command (it is defined in [package.json](./package.json)). 15 | 16 | ``` 17 | npm run upload-data 18 | ``` 19 | 20 | The upload script performs the following steps: 21 | 22 | 1) Upload data as a tileset source 23 | 2) Upload the recipe to create a tileset 24 | 3) Publish the Tileset defined by the recipe 25 | 26 | For more comprehensive tileset management, you can use the [Tilesets CLI](https://docs.mapbox.com/api/maps/#the-tilesets-cli). 27 | 28 | ## Sample Data 29 | 30 | The sample data in this directory is used by the server and the upload scripts. 31 | 32 | To create the county data, we correlated [500k cartographic boundary data](https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html) with the [2018 state FIPS codes](https://www.census.gov/geographies/reference-files/2018/demo/popest/2018-fips.html). Both data sets are sourced from the US Census website. 33 | 34 | The animation sequence data was generated with data from the MIT elections lab, the American Community Survey, and Natural Earth. It combines voter turnout and population data so we can see where participation is high and low across the country. 35 | 36 | Source data is available under the licenses described in the following table: 37 | 38 | | Data | Source | License | 39 | | --- | --- | --- | 40 | | [500k county boundaries](https://www.census.gov/geographies/mapping-files/time-series/geo/carto-boundary-file.html) | US Census Bureau TIGER | Public Domain | 41 | | [County FIPS codes](https://www.census.gov/geographies/reference-files/2018/demo/popest/2018-fips.html) | US Census Bureau | Public Domain | 42 | | [U.S. President 1976–2016](https://dataverse.harvard.edu/dataset.xhtml?persistentId=doi:10.7910/DVN/42MVDX) | MIT Election Data and Science Lab | [CC0 - "Public Domain Dedication"](https://creativecommons.org/publicdomain/zero/1.0/legalcode) | 43 | | [American Community Survey Population Estimates 2016](https://www.census.gov/data/developers/data-sets/acs-1year.html) | US Census Bureau | Public Domain | 44 | | [Natural Earth time zones](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/timezones/) | Natural Earth | [Public Domain Dedication](https://github.com/nvkelso/natural-earth-vector/blob/master/LICENSE.md) | 45 | 46 | The data in this directory is available under the [CC0 - "Public Domain Dedication"](https://creativecommons.org/publicdomain/zero/1.0/legalcode). 47 | 48 | ![public domain dedication license](https://i.creativecommons.org/p/zero/1.0/88x31.png) 49 | 50 | Recommended citation: 51 | Mapbox Solutions Architecture, 2019, "US counties with state names", https://www.mapbox.com/solutions/real-time-maps 52 | 53 | The code in this directory is available under the [BSD-3-Clause license](../LICENSE.md). 54 | 55 | -------------------------------------------------------------------------------- /data/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-preparation", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "@sansumbrella/mapbox-tilesets": { 8 | "version": "0.1.7", 9 | "resolved": "https://registry.npmjs.org/@sansumbrella/mapbox-tilesets/-/mapbox-tilesets-0.1.7.tgz", 10 | "integrity": "sha512-WEEYTOmB5eFusyFcKyHH4m3rf9e3ui1M1D3vbL8y0DylaIz/TtCj1dOndPjjMgtw8G4FYbTg0dzgNcWW8n3rfQ==", 11 | "requires": { 12 | "dotenv": "^8.2.0", 13 | "form-data": "^2.5.1", 14 | "node-fetch": "^2.6.0", 15 | "yargs": "^14.2.0" 16 | } 17 | }, 18 | "ansi-regex": { 19 | "version": "4.1.0", 20 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", 21 | "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" 22 | }, 23 | "ansi-styles": { 24 | "version": "3.2.1", 25 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", 26 | "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", 27 | "requires": { 28 | "color-convert": "^1.9.0" 29 | } 30 | }, 31 | "asynckit": { 32 | "version": "0.4.0", 33 | "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", 34 | "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" 35 | }, 36 | "camelcase": { 37 | "version": "5.3.1", 38 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 39 | "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" 40 | }, 41 | "cliui": { 42 | "version": "5.0.0", 43 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", 44 | "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", 45 | "requires": { 46 | "string-width": "^3.1.0", 47 | "strip-ansi": "^5.2.0", 48 | "wrap-ansi": "^5.1.0" 49 | } 50 | }, 51 | "color-convert": { 52 | "version": "1.9.3", 53 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", 54 | "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", 55 | "requires": { 56 | "color-name": "1.1.3" 57 | } 58 | }, 59 | "color-name": { 60 | "version": "1.1.3", 61 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", 62 | "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" 63 | }, 64 | "combined-stream": { 65 | "version": "1.0.8", 66 | "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", 67 | "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", 68 | "requires": { 69 | "delayed-stream": "~1.0.0" 70 | } 71 | }, 72 | "decamelize": { 73 | "version": "1.2.0", 74 | "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 75 | "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" 76 | }, 77 | "delayed-stream": { 78 | "version": "1.0.0", 79 | "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", 80 | "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" 81 | }, 82 | "dotenv": { 83 | "version": "8.2.0", 84 | "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz", 85 | "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw==" 86 | }, 87 | "emoji-regex": { 88 | "version": "7.0.3", 89 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", 90 | "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" 91 | }, 92 | "find-up": { 93 | "version": "3.0.0", 94 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", 95 | "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", 96 | "requires": { 97 | "locate-path": "^3.0.0" 98 | } 99 | }, 100 | "form-data": { 101 | "version": "2.5.1", 102 | "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", 103 | "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", 104 | "requires": { 105 | "asynckit": "^0.4.0", 106 | "combined-stream": "^1.0.6", 107 | "mime-types": "^2.1.12" 108 | } 109 | }, 110 | "get-caller-file": { 111 | "version": "2.0.5", 112 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 113 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" 114 | }, 115 | "is-fullwidth-code-point": { 116 | "version": "2.0.0", 117 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", 118 | "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=" 119 | }, 120 | "locate-path": { 121 | "version": "3.0.0", 122 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", 123 | "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", 124 | "requires": { 125 | "p-locate": "^3.0.0", 126 | "path-exists": "^3.0.0" 127 | } 128 | }, 129 | "mime-db": { 130 | "version": "1.43.0", 131 | "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.43.0.tgz", 132 | "integrity": "sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ==" 133 | }, 134 | "mime-types": { 135 | "version": "2.1.26", 136 | "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.26.tgz", 137 | "integrity": "sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ==", 138 | "requires": { 139 | "mime-db": "1.43.0" 140 | } 141 | }, 142 | "node-fetch": { 143 | "version": "2.6.0", 144 | "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz", 145 | "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==" 146 | }, 147 | "p-limit": { 148 | "version": "2.2.2", 149 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.2.2.tgz", 150 | "integrity": "sha512-WGR+xHecKTr7EbUEhyLSh5Dube9JtdiG78ufaeLxTgpudf/20KqyMioIUZJAezlTIi6evxuoUs9YXc11cU+yzQ==", 151 | "requires": { 152 | "p-try": "^2.0.0" 153 | } 154 | }, 155 | "p-locate": { 156 | "version": "3.0.0", 157 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", 158 | "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", 159 | "requires": { 160 | "p-limit": "^2.0.0" 161 | } 162 | }, 163 | "p-try": { 164 | "version": "2.2.0", 165 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 166 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" 167 | }, 168 | "path-exists": { 169 | "version": "3.0.0", 170 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", 171 | "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" 172 | }, 173 | "require-directory": { 174 | "version": "2.1.1", 175 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 176 | "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=" 177 | }, 178 | "require-main-filename": { 179 | "version": "2.0.0", 180 | "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", 181 | "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" 182 | }, 183 | "set-blocking": { 184 | "version": "2.0.0", 185 | "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 186 | "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=" 187 | }, 188 | "string-width": { 189 | "version": "3.1.0", 190 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", 191 | "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", 192 | "requires": { 193 | "emoji-regex": "^7.0.1", 194 | "is-fullwidth-code-point": "^2.0.0", 195 | "strip-ansi": "^5.1.0" 196 | } 197 | }, 198 | "strip-ansi": { 199 | "version": "5.2.0", 200 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", 201 | "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", 202 | "requires": { 203 | "ansi-regex": "^4.1.0" 204 | } 205 | }, 206 | "which-module": { 207 | "version": "2.0.0", 208 | "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", 209 | "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" 210 | }, 211 | "wrap-ansi": { 212 | "version": "5.1.0", 213 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", 214 | "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", 215 | "requires": { 216 | "ansi-styles": "^3.2.0", 217 | "string-width": "^3.0.0", 218 | "strip-ansi": "^5.0.0" 219 | } 220 | }, 221 | "y18n": { 222 | "version": "4.0.0", 223 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", 224 | "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" 225 | }, 226 | "yargs": { 227 | "version": "14.2.2", 228 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-14.2.2.tgz", 229 | "integrity": "sha512-/4ld+4VV5RnrynMhPZJ/ZpOCGSCeghMykZ3BhdFBDa9Wy/RH6uEGNWDJog+aUlq+9OM1CFTgtYRW5Is1Po9NOA==", 230 | "requires": { 231 | "cliui": "^5.0.0", 232 | "decamelize": "^1.2.0", 233 | "find-up": "^3.0.0", 234 | "get-caller-file": "^2.0.1", 235 | "require-directory": "^2.1.1", 236 | "require-main-filename": "^2.0.0", 237 | "set-blocking": "^2.0.0", 238 | "string-width": "^3.0.0", 239 | "which-module": "^2.0.0", 240 | "y18n": "^4.0.0", 241 | "yargs-parser": "^15.0.0" 242 | } 243 | }, 244 | "yargs-parser": { 245 | "version": "15.0.1", 246 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-15.0.1.tgz", 247 | "integrity": "sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw==", 248 | "requires": { 249 | "camelcase": "^5.0.0", 250 | "decamelize": "^1.2.0" 251 | } 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /data/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "data-preparation", 3 | "version": "1.0.0", 4 | "description": "Client for the Mapbox map matching and tilesets APIs", 5 | "main": "index.js", 6 | "repository": "https://github.com/mapbox/blueprint-realtime-pois-everywhere", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1", 9 | "upload-data": "node uploadData.js" 10 | }, 11 | "author": "David Wicks (https://sansumbrella.com/)", 12 | "license": "(BSD-3-Clause OR CC0-1.0)", 13 | "dependencies": { 14 | "@sansumbrella/mapbox-tilesets": "^0.1.7", 15 | "dotenv": "^8.1.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /data/recipe.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "layers": { 4 | "county": { 5 | "source": "mapbox://tileset-source/USER_NAME/us-counties", 6 | "minzoom": 1, 7 | "maxzoom": 16, 8 | "features": { 9 | "id": ["get", "GEOID"], 10 | "filter": [ 11 | "match", 12 | ["to-string", ["get", "STATE_NAME"]], 13 | "Alabama", 14 | true, 15 | "Arizona", 16 | true, 17 | "Arkansas", 18 | true, 19 | "California", 20 | true, 21 | "Colorado", 22 | true, 23 | "Connecticut", 24 | true, 25 | "Delaware", 26 | true, 27 | "District of Columbia", 28 | true, 29 | "Florida", 30 | true, 31 | "Georgia", 32 | true, 33 | "Idaho", 34 | true, 35 | "Illinois", 36 | true, 37 | "Indiana", 38 | true, 39 | "Iowa", 40 | true, 41 | "Kansas", 42 | true, 43 | "Kentucky", 44 | true, 45 | "Louisiana", 46 | true, 47 | "Maine", 48 | true, 49 | "Maryland", 50 | true, 51 | "Massachusetts", 52 | true, 53 | "Michigan", 54 | true, 55 | "Minnesota", 56 | true, 57 | "Mississippi", 58 | true, 59 | "Missouri", 60 | true, 61 | "Montana", 62 | true, 63 | "Nebraska", 64 | true, 65 | "Nevada", 66 | true, 67 | "New Hampshire", 68 | true, 69 | "New Jersey", 70 | true, 71 | "New Mexico", 72 | true, 73 | "New York", 74 | true, 75 | "North Carolina", 76 | true, 77 | "North Dakota", 78 | true, 79 | "Ohio", 80 | true, 81 | "Oklahoma", 82 | true, 83 | "Oregon", 84 | true, 85 | "Pennsylvania", 86 | true, 87 | "Rhode Island", 88 | true, 89 | "South Carolina", 90 | true, 91 | "South Dakota", 92 | true, 93 | "Tennessee", 94 | true, 95 | "Texas", 96 | true, 97 | "Utah", 98 | true, 99 | "Vermont", 100 | true, 101 | "Virginia", 102 | true, 103 | "Washington", 104 | true, 105 | "West Virginia", 106 | true, 107 | "Wisconsin", 108 | true, 109 | "Wyoming", 110 | true, 111 | false 112 | ], 113 | "attributes": { 114 | "set": { 115 | "name": ["get", "NAME"] 116 | }, 117 | "allowed_output": ["name"] 118 | } 119 | } 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /data/uploadData.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config(); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const { TilesetsAPI } = require("@sansumbrella/mapbox-tilesets"); 5 | 6 | /** 7 | * Script for creating a tileset using the Mapbox Tilesets API. 8 | * Uploads sample data, a recipe, and publishes a tileset to your account 9 | * as configured in your shell environment. 10 | */ 11 | async function main() { 12 | const user = process.env.MAPBOX_USERNAME; 13 | const token = process.env.MAPBOX_ACCESS_TOKEN; 14 | const sourceName = "us-counties"; 15 | const tilesetName = "conus-counties"; 16 | const dataFile = "./counties.ndjson"; 17 | const recipeFile = "./recipe.json"; 18 | 19 | if (!user || !token) { 20 | console.error(`A username and access token are required. Set them in your environment or in an .env file. You provided username: ${user}, access token: ${token}`); 21 | return 1; 22 | } 23 | 24 | try { 25 | const tilesets = new TilesetsAPI(user, token); 26 | const sources = await tilesets.listSources(); 27 | if (sources.some(sourceID => sourceID.indexOf(sourceName) !== -1)) { 28 | console.log(`you (${user}) already have a source by this name (${sourceName}); skipping upload. Delete the source first if you wish to update the data.`); 29 | } else { 30 | console.log("uploading source file"); 31 | const sourceData = fs.createReadStream(path.resolve(dataFile)); 32 | const sourceUploadJob = await tilesets.uploadSource(sourceData, sourceName); 33 | console.log(sourceUploadJob); 34 | if (!sourceUploadJob.success) { 35 | return 1; 36 | } 37 | } 38 | 39 | console.log(`uploading or updating recipe for ${tilesetName}`); 40 | const recipeData = JSON.parse(fs.readFileSync(recipeFile).toString().replace(/USER_NAME/g, user)); 41 | const validationJob = await tilesets.validateRecipe(JSON.stringify(recipeData)); 42 | console.log(validationJob); 43 | const recipeUploadJob = await tilesets.uploadRecipe(recipeData, tilesetName); 44 | console.log(recipeUploadJob); 45 | 46 | console.log(`publishing tileset ${user}.${tilesetName}`); 47 | const publishJob = await tilesets.publishTileset(tilesetName); 48 | console.log(publishJob); 49 | } catch (err) { 50 | console.error(err); 51 | process.exit(1); 52 | } 53 | } 54 | 55 | main(); 56 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-mapping-solution", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "ansi-regex": { 8 | "version": "4.1.0", 9 | "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", 10 | "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", 11 | "dev": true 12 | }, 13 | "ansi-styles": { 14 | "version": "3.2.1", 15 | "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", 16 | "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", 17 | "dev": true, 18 | "requires": { 19 | "color-convert": "^1.9.0" 20 | } 21 | }, 22 | "camelcase": { 23 | "version": "5.3.1", 24 | "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", 25 | "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", 26 | "dev": true 27 | }, 28 | "chalk": { 29 | "version": "2.4.2", 30 | "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", 31 | "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", 32 | "dev": true, 33 | "requires": { 34 | "ansi-styles": "^3.2.1", 35 | "escape-string-regexp": "^1.0.5", 36 | "supports-color": "^5.3.0" 37 | }, 38 | "dependencies": { 39 | "supports-color": { 40 | "version": "5.5.0", 41 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", 42 | "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", 43 | "dev": true, 44 | "requires": { 45 | "has-flag": "^3.0.0" 46 | } 47 | } 48 | } 49 | }, 50 | "cliui": { 51 | "version": "5.0.0", 52 | "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", 53 | "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", 54 | "dev": true, 55 | "requires": { 56 | "string-width": "^3.1.0", 57 | "strip-ansi": "^5.2.0", 58 | "wrap-ansi": "^5.1.0" 59 | } 60 | }, 61 | "color-convert": { 62 | "version": "1.9.3", 63 | "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", 64 | "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", 65 | "dev": true, 66 | "requires": { 67 | "color-name": "1.1.3" 68 | } 69 | }, 70 | "color-name": { 71 | "version": "1.1.3", 72 | "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", 73 | "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", 74 | "dev": true 75 | }, 76 | "concurrently": { 77 | "version": "5.2.0", 78 | "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-5.2.0.tgz", 79 | "integrity": "sha512-XxcDbQ4/43d6CxR7+iV8IZXhur4KbmEJk1CetVMUqCy34z9l0DkszbY+/9wvmSnToTej0SYomc2WSRH+L0zVJw==", 80 | "dev": true, 81 | "requires": { 82 | "chalk": "^2.4.2", 83 | "date-fns": "^2.0.1", 84 | "lodash": "^4.17.15", 85 | "read-pkg": "^4.0.1", 86 | "rxjs": "^6.5.2", 87 | "spawn-command": "^0.0.2-1", 88 | "supports-color": "^6.1.0", 89 | "tree-kill": "^1.2.2", 90 | "yargs": "^13.3.0" 91 | } 92 | }, 93 | "date-fns": { 94 | "version": "2.15.0", 95 | "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.15.0.tgz", 96 | "integrity": "sha512-ZCPzAMJZn3rNUvvQIMlXhDr4A+Ar07eLeGsGREoWU19a3Pqf5oYa+ccd+B3F6XVtQY6HANMFdOQ8A+ipFnvJdQ==", 97 | "dev": true 98 | }, 99 | "decamelize": { 100 | "version": "1.2.0", 101 | "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", 102 | "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", 103 | "dev": true 104 | }, 105 | "emoji-regex": { 106 | "version": "7.0.3", 107 | "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", 108 | "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", 109 | "dev": true 110 | }, 111 | "error-ex": { 112 | "version": "1.3.2", 113 | "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", 114 | "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", 115 | "dev": true, 116 | "requires": { 117 | "is-arrayish": "^0.2.1" 118 | } 119 | }, 120 | "escape-string-regexp": { 121 | "version": "1.0.5", 122 | "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", 123 | "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", 124 | "dev": true 125 | }, 126 | "find-up": { 127 | "version": "3.0.0", 128 | "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", 129 | "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", 130 | "dev": true, 131 | "requires": { 132 | "locate-path": "^3.0.0" 133 | } 134 | }, 135 | "get-caller-file": { 136 | "version": "2.0.5", 137 | "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", 138 | "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", 139 | "dev": true 140 | }, 141 | "has-flag": { 142 | "version": "3.0.0", 143 | "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", 144 | "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", 145 | "dev": true 146 | }, 147 | "hosted-git-info": { 148 | "version": "2.8.8", 149 | "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.8.tgz", 150 | "integrity": "sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg==", 151 | "dev": true 152 | }, 153 | "is-arrayish": { 154 | "version": "0.2.1", 155 | "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", 156 | "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", 157 | "dev": true 158 | }, 159 | "is-fullwidth-code-point": { 160 | "version": "2.0.0", 161 | "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", 162 | "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", 163 | "dev": true 164 | }, 165 | "json-parse-better-errors": { 166 | "version": "1.0.2", 167 | "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", 168 | "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", 169 | "dev": true 170 | }, 171 | "locate-path": { 172 | "version": "3.0.0", 173 | "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", 174 | "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", 175 | "dev": true, 176 | "requires": { 177 | "p-locate": "^3.0.0", 178 | "path-exists": "^3.0.0" 179 | } 180 | }, 181 | "lodash": { 182 | "version": "4.17.19", 183 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.19.tgz", 184 | "integrity": "sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==", 185 | "dev": true 186 | }, 187 | "normalize-package-data": { 188 | "version": "2.5.0", 189 | "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", 190 | "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", 191 | "dev": true, 192 | "requires": { 193 | "hosted-git-info": "^2.1.4", 194 | "resolve": "^1.10.0", 195 | "semver": "2 || 3 || 4 || 5", 196 | "validate-npm-package-license": "^3.0.1" 197 | } 198 | }, 199 | "p-limit": { 200 | "version": "2.3.0", 201 | "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", 202 | "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", 203 | "dev": true, 204 | "requires": { 205 | "p-try": "^2.0.0" 206 | } 207 | }, 208 | "p-locate": { 209 | "version": "3.0.0", 210 | "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", 211 | "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", 212 | "dev": true, 213 | "requires": { 214 | "p-limit": "^2.0.0" 215 | } 216 | }, 217 | "p-try": { 218 | "version": "2.2.0", 219 | "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", 220 | "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", 221 | "dev": true 222 | }, 223 | "parse-json": { 224 | "version": "4.0.0", 225 | "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", 226 | "integrity": "sha1-vjX1Qlvh9/bHRxhPmKeIy5lHfuA=", 227 | "dev": true, 228 | "requires": { 229 | "error-ex": "^1.3.1", 230 | "json-parse-better-errors": "^1.0.1" 231 | } 232 | }, 233 | "path-exists": { 234 | "version": "3.0.0", 235 | "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", 236 | "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", 237 | "dev": true 238 | }, 239 | "path-parse": { 240 | "version": "1.0.6", 241 | "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", 242 | "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", 243 | "dev": true 244 | }, 245 | "pify": { 246 | "version": "3.0.0", 247 | "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", 248 | "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", 249 | "dev": true 250 | }, 251 | "read-pkg": { 252 | "version": "4.0.1", 253 | "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-4.0.1.tgz", 254 | "integrity": "sha1-ljYlN48+HE1IyFhytabsfV0JMjc=", 255 | "dev": true, 256 | "requires": { 257 | "normalize-package-data": "^2.3.2", 258 | "parse-json": "^4.0.0", 259 | "pify": "^3.0.0" 260 | } 261 | }, 262 | "require-directory": { 263 | "version": "2.1.1", 264 | "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", 265 | "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", 266 | "dev": true 267 | }, 268 | "require-main-filename": { 269 | "version": "2.0.0", 270 | "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", 271 | "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", 272 | "dev": true 273 | }, 274 | "resolve": { 275 | "version": "1.17.0", 276 | "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.17.0.tgz", 277 | "integrity": "sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==", 278 | "dev": true, 279 | "requires": { 280 | "path-parse": "^1.0.6" 281 | } 282 | }, 283 | "rxjs": { 284 | "version": "6.6.0", 285 | "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.0.tgz", 286 | "integrity": "sha512-3HMA8z/Oz61DUHe+SdOiQyzIf4tOx5oQHmMir7IZEu6TMqCLHT4LRcmNaUS0NwOz8VLvmmBduMsoaUvMaIiqzg==", 287 | "dev": true, 288 | "requires": { 289 | "tslib": "^1.9.0" 290 | } 291 | }, 292 | "semver": { 293 | "version": "5.7.1", 294 | "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", 295 | "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", 296 | "dev": true 297 | }, 298 | "set-blocking": { 299 | "version": "2.0.0", 300 | "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", 301 | "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", 302 | "dev": true 303 | }, 304 | "spawn-command": { 305 | "version": "0.0.2-1", 306 | "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2-1.tgz", 307 | "integrity": "sha1-YvXpRmmBwbeW3Fkpk34RycaSG9A=", 308 | "dev": true 309 | }, 310 | "spdx-correct": { 311 | "version": "3.1.1", 312 | "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz", 313 | "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==", 314 | "dev": true, 315 | "requires": { 316 | "spdx-expression-parse": "^3.0.0", 317 | "spdx-license-ids": "^3.0.0" 318 | } 319 | }, 320 | "spdx-exceptions": { 321 | "version": "2.3.0", 322 | "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", 323 | "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==", 324 | "dev": true 325 | }, 326 | "spdx-expression-parse": { 327 | "version": "3.0.1", 328 | "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", 329 | "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", 330 | "dev": true, 331 | "requires": { 332 | "spdx-exceptions": "^2.1.0", 333 | "spdx-license-ids": "^3.0.0" 334 | } 335 | }, 336 | "spdx-license-ids": { 337 | "version": "3.0.5", 338 | "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", 339 | "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==", 340 | "dev": true 341 | }, 342 | "string-width": { 343 | "version": "3.1.0", 344 | "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", 345 | "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", 346 | "dev": true, 347 | "requires": { 348 | "emoji-regex": "^7.0.1", 349 | "is-fullwidth-code-point": "^2.0.0", 350 | "strip-ansi": "^5.1.0" 351 | } 352 | }, 353 | "strip-ansi": { 354 | "version": "5.2.0", 355 | "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", 356 | "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", 357 | "dev": true, 358 | "requires": { 359 | "ansi-regex": "^4.1.0" 360 | } 361 | }, 362 | "supports-color": { 363 | "version": "6.1.0", 364 | "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-6.1.0.tgz", 365 | "integrity": "sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==", 366 | "dev": true, 367 | "requires": { 368 | "has-flag": "^3.0.0" 369 | } 370 | }, 371 | "tree-kill": { 372 | "version": "1.2.2", 373 | "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", 374 | "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", 375 | "dev": true 376 | }, 377 | "tslib": { 378 | "version": "1.13.0", 379 | "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", 380 | "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==", 381 | "dev": true 382 | }, 383 | "validate-npm-package-license": { 384 | "version": "3.0.4", 385 | "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", 386 | "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", 387 | "dev": true, 388 | "requires": { 389 | "spdx-correct": "^3.0.0", 390 | "spdx-expression-parse": "^3.0.0" 391 | } 392 | }, 393 | "which-module": { 394 | "version": "2.0.0", 395 | "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", 396 | "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", 397 | "dev": true 398 | }, 399 | "wrap-ansi": { 400 | "version": "5.1.0", 401 | "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", 402 | "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", 403 | "dev": true, 404 | "requires": { 405 | "ansi-styles": "^3.2.0", 406 | "string-width": "^3.0.0", 407 | "strip-ansi": "^5.0.0" 408 | } 409 | }, 410 | "y18n": { 411 | "version": "4.0.0", 412 | "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", 413 | "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", 414 | "dev": true 415 | }, 416 | "yargs": { 417 | "version": "13.3.2", 418 | "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", 419 | "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", 420 | "dev": true, 421 | "requires": { 422 | "cliui": "^5.0.0", 423 | "find-up": "^3.0.0", 424 | "get-caller-file": "^2.0.1", 425 | "require-directory": "^2.1.1", 426 | "require-main-filename": "^2.0.0", 427 | "set-blocking": "^2.0.0", 428 | "string-width": "^3.0.0", 429 | "which-module": "^2.0.0", 430 | "y18n": "^4.0.0", 431 | "yargs-parser": "^13.1.2" 432 | } 433 | }, 434 | "yargs-parser": { 435 | "version": "13.1.2", 436 | "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", 437 | "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", 438 | "dev": true, 439 | "requires": { 440 | "camelcase": "^5.0.0", 441 | "decamelize": "^1.2.0" 442 | } 443 | } 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-mapping-solution", 3 | "version": "1.0.0", 4 | "author": "David Wicks (https://mapbox.com/)", 5 | "license": "BSD-3-Clause", 6 | "start_url": ".", 7 | "scripts": { 8 | "copy-env": "if test -f '.env'; then (cp .env client/) && (cp .env data/); else echo 'no .env file found; assuming you have set REACT_APP_MAPBOX_TOKEN'; fi", 9 | "start-samples": "concurrently \"(cd server && npm start)\" \"(cd client && npm start)\"", 10 | "start": "npm run copy-env && npm run start-samples", 11 | "postinstall": "concurrently \"(cd server && npm install)\" \"(cd ./client && npm install)\" \"(cd ./data && npm install)\"", 12 | "upload-data": "npm run copy-env && (cd data && npm run upload-data)" 13 | }, 14 | "devDependencies": { 15 | "concurrently": "^5.2.0" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # Rename this file to `.env` to make the values you assign 2 | # available to the example application. 3 | # The token entered here will be used by the web client to load the map 4 | REACT_APP_MAPBOX_TOKEN= 5 | 6 | # To use the Tilesets API, add an access token token 7 | # with tilesets:read, tilesets:write, and tilesets:list scopes 8 | # This token and your username are used by the data script 9 | MAPBOX_USERNAME= 10 | MAPBOX_ACCESS_TOKEN= 11 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Mock Election Returns API 2 | 3 | This server provides an SSE endpoint for real-time mock election returns. 4 | 5 | You can view the data events in your command prompt with curl: 6 | `curl -N localhost:5000/election-stream` 7 | 8 | ## Quick Start 9 | 10 | The server is run by the root package's `npm run start` script, which also starts the client application. 11 | -------------------------------------------------------------------------------- /server/availability.js: -------------------------------------------------------------------------------- 1 | const OpenSimplexNoise = require("open-simplex-noise").default; 2 | const generator = new OpenSimplexNoise(Date.now()); 3 | 4 | /// Returns availability for a single location 5 | function availabilityForId(id) { 6 | const time = Date.now() / (1000 * 10); 7 | return generator.noise2D(+id, time) * 0.5 + 0.5; 8 | } 9 | 10 | /// Returns space availability for many locations 11 | function availabilityForIds(ids) { 12 | const idsToAvailability = ids.map(id => availabilityForId(id)); 13 | return idsToAvailability; 14 | } 15 | 16 | module.exports = { 17 | availabilityForIds 18 | } -------------------------------------------------------------------------------- /server/availability.test.js: -------------------------------------------------------------------------------- 1 | const { availabilityForIds } = require("./availability"); 2 | 3 | describe("generating random numbers for ids", () => { 4 | test("it generates a number in range [0, 1) for each input id", () => { 5 | const input = ["5", "-2", "300"]; 6 | const output = availabilityForIds(input); 7 | expect(output.length).toEqual(input.length); 8 | output.forEach(value => { 9 | expect(value).toBeGreaterThanOrEqual(0); 10 | expect(value).toBeLessThan(1); 11 | }) 12 | }); 13 | 14 | test("it returns an empty array if no ids are provided", () => { 15 | const input = []; 16 | const output = availabilityForIds(input); 17 | expect(output).toEqual([]); 18 | }); 19 | }); -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express(); 3 | const port = 5000; 4 | const EOM = "\n\n"; 5 | 6 | // Enable CORS for local development 7 | app.use(function (req, res, next) { 8 | res.header("Access-Control-Allow-Origin", "*"); // update to match the domain you will make the request from 9 | res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept"); 10 | res.header("Access-Control-Allow-Methods", "GET"); 11 | next(); 12 | }); 13 | 14 | // Import mock election data as an observable subject from mock election returns 15 | const { RESET, electionSubject, startAnimation } = require("./mockElectionReturns"); 16 | 17 | // Keep a record of all election data events so we can send 18 | // new clients the full, latest data when they connect. 19 | let updatedCounties = []; 20 | electionSubject.subscribe(update => { 21 | if (update === RESET) { 22 | updatedCounties = []; 23 | } else { 24 | updatedCounties = updatedCounties.concat(update); 25 | } 26 | }); 27 | 28 | // Create an SSE endpoint where clients can receive election data updates 29 | app.get("/election-stream", function (req, res) { 30 | // Start an SSE connection 31 | req.socket.setTimeout(2147483647); 32 | res.writeHead(200, { 33 | "Content-Type": "text/event-stream", 34 | "Cache-Control": "no-cache", 35 | "Connection": "keep-alive" 36 | }); 37 | res.write(EOM); 38 | 39 | // Send complete data for election in first message 40 | res.write(`data: ${encodeURI(JSON.stringify(updatedCounties))}${EOM}`); 41 | 42 | // Subscribe client to incremental updates 43 | const subscription = electionSubject.subscribe(update => { 44 | if (update === RESET) { 45 | res.write(`data: RESET${EOM}`); 46 | } else if (update.length > 0) { 47 | res.write(`data: ${encodeURI(JSON.stringify(update))}${EOM}`); 48 | } 49 | }); 50 | const referer = req.headers.referer; 51 | console.log(`Client connected: ${referer}`); 52 | 53 | // When client disconnects, cancel their subscription 54 | req.on("close", function unsubscribe() { 55 | subscription.unsubscribe(); 56 | console.log(`Client disconnected: ${referer}`); 57 | }); 58 | }); 59 | 60 | // Start the server 61 | app.listen(port, () => console.log(`Server listening on port ${port} `)); 62 | // Start playing back the election data 63 | startAnimation({ duration: 30000, updateInterval: 1000 }); 64 | -------------------------------------------------------------------------------- /server/mockElectionReturns.js: -------------------------------------------------------------------------------- 1 | const { Subject } = require("rxjs"); 2 | const { remap } = require("./remap"); 3 | /** 4 | * Message sent when animation is starting from beginning 5 | */ 6 | const RESET = "RESET"; 7 | 8 | const ANIMATION_DEFAULTS = { 9 | duration: 10000, 10 | updateInterval: 200, 11 | endHold: 1500 12 | }; 13 | 14 | /** 15 | * Observable election information. 16 | * Emits county election data as an array of objects [{geoid, votes_total, population}] 17 | * Also emits RESET when election animation is restarting 18 | */ 19 | const electionSubject = new Subject(); 20 | 21 | // Load the historic voting data and sort it for animating sequentially 22 | const voteSequence = require("../data/votes_animation_sequence.json") 23 | .sort((a, b) => a.seq < b.seq ? -1 : 1) 24 | .map(value => { return { ...value, hasUpdated: false } }); 25 | 26 | /** 27 | * Start the election update animation 28 | * @param {{duration: number, updateInterval: number, endHold: number}} options - animation timings 29 | */ 30 | function startAnimation(options = {}) { 31 | const { duration, updateInterval, endHold } = { ...ANIMATION_DEFAULTS, ...options }; 32 | let startTime = Date.now(); 33 | let endTime = startTime + duration; 34 | let resetTime = endTime + endHold; 35 | const interval = setInterval(updateElectionStatus, updateInterval); 36 | 37 | // Send a change in the election status to all listeners 38 | function updateElectionStatus() { 39 | const now = Date.now(); 40 | const currentTime = remap(startTime, endTime, now, 5.0, 10.0); 41 | const data = newlyUpdatedCounties(currentTime); 42 | electionSubject.next(data); 43 | if (now >= resetTime) { 44 | reset(); 45 | } 46 | } 47 | 48 | // Send reset to all listeners and play over from beginning 49 | function reset() { 50 | startTime = Date.now(); 51 | endTime = startTime + duration; 52 | resetTime = endTime + endHold; 53 | updatedCounties = []; 54 | for (let i = 0; i < voteSequence.length; i += 1) { 55 | voteSequence[i].hasUpdated = false; 56 | } 57 | electionSubject.next(RESET); 58 | } 59 | 60 | return () => { 61 | clearInterval(interval); 62 | } 63 | } 64 | 65 | /** 66 | * Returns an array of information about counties that have 67 | * updated within the last animation time interval. 68 | * Marks updated counties so they are not re-sent in future calls. 69 | * @param {Number} now - the current sequence time 70 | */ 71 | function newlyUpdatedCounties(now) { 72 | const updates = []; 73 | for (let i = 0; i < voteSequence.length; i += 1) { 74 | const county = voteSequence[i]; 75 | if (county.seq > now) { 76 | break; 77 | } 78 | if (!county.hasUpdated) { 79 | county.hasUpdated = true; 80 | updates.push({ 81 | geoid: county.geoid, 82 | votes_total: +county.votes_total, 83 | population: +county.pop2016 84 | }); 85 | } 86 | } 87 | return updates; 88 | } 89 | 90 | module.exports = { 91 | electionSubject, 92 | RESET, 93 | startAnimation 94 | }; 95 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "real-time-mapping-server", 3 | "description": "Simulated real-time election return SSE server.", 4 | "version": "1.0.0", 5 | "author": "David Wicks (https://sansumbrella.com/)", 6 | "license": "BSD-3-Clause", 7 | "main": "index.js", 8 | "scripts": { 9 | "start": "node index.js", 10 | "test": "jest --watch" 11 | }, 12 | "keywords": [], 13 | "dependencies": { 14 | "express": "^4.17.1", 15 | "rxjs": "^6.5.4" 16 | }, 17 | "devDependencies": { 18 | "jest": "^26.2.1" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/precinct.js: -------------------------------------------------------------------------------- 1 | const encode = require("hashcode").hashCode(); 2 | const OpenSimplexNoise = require("open-simplex-noise").default; 3 | const generator = new OpenSimplexNoise(Date.now()); 4 | 5 | /// Returns availability for a single location 6 | function precinctStatus(id) { 7 | const time = Date.now() / (1000 * 10); 8 | const code = encode.value(id); 9 | return generator.noise2D(code, time) * 0.5 + 0.5; 10 | } 11 | 12 | /// Returns space availability for many locations 13 | function precinctStatuses(ids) { 14 | return ids.map(id => precinctStatus(id)); 15 | } 16 | 17 | module.exports = { 18 | precinctStatuses 19 | } -------------------------------------------------------------------------------- /server/precinct.test.js: -------------------------------------------------------------------------------- 1 | const { precinctStatuses } = require("./precinct"); 2 | const { hashCode } = require("hashcode"); 3 | 4 | describe("generating random numbers for precincts", () => { 5 | test("it generates a number in range [0, 1) for each input id", () => { 6 | const input = ["O90", "I200", "A505"]; 7 | const output = precinctStatuses(input); 8 | expect(output.length).toEqual(input.length); 9 | output.forEach(value => { 10 | expect(value).toBeGreaterThanOrEqual(0); 11 | expect(value).toBeLessThan(1); 12 | }) 13 | }); 14 | 15 | test("it returns an empty array if no ids are provided", () => { 16 | const input = []; 17 | const output = precinctStatuses(input); 18 | expect(output).toEqual([]); 19 | }); 20 | }); -------------------------------------------------------------------------------- /server/remap.js: -------------------------------------------------------------------------------- 1 | function remap(ia, ib, iv, oa, ob) { 2 | const t = (iv - ia) / (ib - ia); 3 | return mix(oa, ob, t); 4 | } 5 | 6 | function mix(a, b, t) { 7 | return a + (b - a) * t; 8 | } 9 | 10 | module.exports = { 11 | remap, 12 | mix 13 | } 14 | -------------------------------------------------------------------------------- /server/remap.test.js: -------------------------------------------------------------------------------- 1 | const { mix, remap } = require("./remap"); 2 | 3 | describe("mix", () => { 4 | it("interpolates between two increasing values", () => { 5 | const result = mix(0.0, 1.0, 0.5); 6 | expect(result).toBe(0.5); 7 | }); 8 | 9 | it("interpolates between two decreasing values", () => { 10 | const result = mix(0.0, -1.0, 0.5); 11 | expect(result).toBe(-0.5); 12 | }); 13 | }) 14 | 15 | describe("remap", () => { 16 | it("changes a normalized range to non-normalized range", () => { 17 | const result = remap(0.0, 1.0, 0.5, 10, 20); 18 | expect(result).toBe(15); 19 | }); 20 | it("changes a non-normalized range to a normalized range", () => { 21 | const result = remap(10, 20, 15, 0.0, 1.0); 22 | expect(result).toBe(0.5); 23 | }); 24 | it("converts between inverted ranges", () => { 25 | const result = remap(10, 0, 5, -8, -10); 26 | expect(result).toBe(-9); 27 | }); 28 | }); --------------------------------------------------------------------------------