├── .gitignore ├── DEVELOPMENT.md ├── HOWTODIGITIZE.md ├── HOWTOMAP.md ├── LICENSE ├── PARTS.md ├── README.md ├── SETUP.md ├── bench └── extract.bench.js ├── config ├── wifi.json ├── wpa_supplicant.conf └── wpa_supplicant.conf.template ├── data-model.md ├── images ├── CurbWheelTestScreen.jpg ├── curb-wheel-3d-base.jpg ├── curb-wheel-3d-cap.jpg ├── digitizer1.png ├── digitizer3.png ├── digitizer4.png ├── etcher_example.png ├── howtomap1.png ├── howtomap2.png ├── howtomap3.png ├── howtomap4.png ├── insertSD.jpg ├── overlaptrick1.png ├── overlaptrick2.png ├── pi_1.JPG ├── pi_2.JPG ├── pi_3.JPG ├── pi_4.JPG ├── pi_5.JPG ├── pi_parity_1.JPG ├── pi_parity_2.JPG ├── wheel_1.JPG ├── wheel_2.png ├── wheel_3.JPG ├── wheel_4.JPG ├── wheel_5.JPG ├── wheel_6.JPG └── wheel_app_digitizer.png ├── package-lock.json ├── package.json ├── python ├── wheel-simulator.py └── wheel.py ├── setup-ap.sh ├── setup-ramdisk-simulator.sh ├── setup-ramdisk.sh ├── setup-tileserver-simulator.sh ├── setup-tileserver.sh ├── setup-wifi.sh ├── setup.sh ├── src ├── graph.js └── server.js ├── startup-simulator.sh ├── startup.rc.d ├── startup.sh ├── static ├── app.js ├── autocomplete.css ├── autocomplete.js ├── basics.css ├── bulma.min.css ├── d3.v4.min.js ├── digitizer.js ├── frontend.md ├── images │ ├── back.svg │ ├── cog.svg │ ├── icons.svg │ └── plus.svg ├── map.js ├── mapbox-gl.css ├── mapbox-gl.js ├── sampleSurveyOutput │ └── spans.geojson ├── style.css └── turf.min.js ├── switch-to-ap.sh ├── switch-to-wifi.sh ├── templates ├── 404.html ├── admin.html ├── digitizer.html └── index.html ├── test ├── fixtures │ ├── dc.osm.pbf │ ├── honolulu.json │ ├── honolulu.osm.pbf │ ├── nyc.osm.pbf │ ├── oakland.osm.pbf │ ├── sign-1.jpg │ ├── sign-2.jpg │ ├── sign-3.jpg │ └── sign-4.png ├── graph.test.js └── server.test.js └── upgrade_wheel.sh /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 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 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # DS Store 107 | .DS_Store 108 | 109 | # wheel local files 110 | ram/ 111 | wheel.pid 112 | static/images/survey/ 113 | graph.json 114 | export.zip 115 | export 116 | extract.mbtiles 117 | -------------------------------------------------------------------------------- /DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | Development 2 | --- 3 | 4 | ### run 5 | 6 | ```sh 7 | npm start 8 | ``` 9 | 10 | ### test 11 | 12 | ```sh 13 | npm t 14 | ``` 15 | 16 | ### lint 17 | 18 | Checks for syntax errors and automatically formats code. 19 | 20 | ```sh 21 | npm run lint 22 | ``` 23 | -------------------------------------------------------------------------------- /HOWTODIGITIZE.md: -------------------------------------------------------------------------------- 1 | ## How to digitize the data into a CurbLR feed 2 | 3 | The digitizer can be run locally or accessed through a hosted version. It will pull in the data you uploaded and display the curb segments on a map, alongside images that were captured. 4 | 5 | 6 | 7 | On the left are tables where you will enter the regulation information. The top table contains all the individual segments. The second contains the regulation information for a selected segment. The third contains timespan information for a selected segment. 8 | 9 | To begin, select an individual segment and complete any relevant fields in the first table. Then move to the second table and enter in the relevant regulation info (e.g. “activity = no parking”). If that regulation has a timespan restriction, move to the third table and enter it there. 10 | 11 | 12 | 13 | Most cities have a handful of rules that repeat over and over again, so to make digitizing more efficient, you can give the regulation a name and make it a template. Once a regulation is templated, you can apply it to other curb spans using a dropdown, or by copying and pasting over multiple cells. You can do the same thing to create and apply timespan templates, as shown above. 14 | 15 | More detailed instructions and tips are available [here](https://github.com/sharedstreets/curbwheel-digitizer/blob/master/usage.md). 16 | 17 | When you’re finished, clicking “Export” will download the CurbLR JSON file as well as a GeoJSON of point features (such as photo locations and fire hydrants). 18 | 19 | This testing version does not generate the metadata text for the CurbLR JSON, so you will need to add that to your data feed. Open the JSON in a text editor. It will begin with the text, ``{"type":"FeatureCollection",`` Right after that text (immediately after the comma), paste the manifest template text as seen in the image below (the pasted text is highlighted). 20 | 21 | 22 | 23 | Customize the fields to be correct for your particular agency, timezone, etc. Pay special attention to the `priorityHierarchy` field; this should be a ranked list of all your `priorityCategory` names, in order of highest precedence. Save the JSON file when you are finished. You can give the file a new name if you prefer. 24 | 25 | Test for the sample manifest is contained below: 26 | 27 | ```JSON 28 | "manifest": { 29 | "createdDate": "2020-05-12T11:40:45Z", 30 | "lastUpdatedDate": "2020-10-10T17:40:45Z", 31 | "priorityHierarchy": [ 32 | "no standing", 33 | "construction", 34 | "temporary restriction", 35 | "restricted standing", 36 | "standing", 37 | "restricted loading", 38 | "loading", 39 | "no parking", 40 | "restricted parking", 41 | "paid parking", 42 | "free parking" 43 | ], 44 | "curblrVersion": "1.1.0", 45 | "timeZone": "America/Los_Angeles", 46 | "currency": "USD", 47 | "unitHeightLength": "feet", 48 | "unitWeight": "tons", 49 | "authority": { 50 | "name": "Your Transportation Agency Name", 51 | "url": "https://www.youragencyurl.gov", 52 | "phone": "+15551231234" 53 | } 54 | } 55 | ``` 56 | -------------------------------------------------------------------------------- /HOWTOMAP.md: -------------------------------------------------------------------------------- 1 | # How to survey streets with the CurbWheel 2 | 3 | This [slide deck](https://docs.google.com/presentation/d/1NqRnIblEEMXaFtzdQwLsId2Zoh3sRvoiIRdQ_2HDb48/edit#slide=id.ga62d08dcdb_0_285) contains the most up-to-date instructions, tips, and tricks. Highlights are posted below for reference. 4 | 5 | 6 |

7 | 8 |

9 | 10 |

11 | 12 | 13 | We are currently working to complete the upload notification with link. 14 | 15 | ## Tips and tricks 16 | 17 | ### Layering regulations: Efficient ways to map 18 | 19 | Streets usually have multiple regulations along them - often with a main rule, and others layered on top. For example, this diagram shows a street that is a parking zone with frequent curb cuts. 20 | 21 | 22 | 23 | This creates 9 different individual zones on the street. A CurbWheel user could start and stop 9 individual parking and curb cut features… or they could map the entire street as a parking zone, and layer on the curb cuts, as shown below: 24 | 25 | 26 | 27 | The app allows you to add multiple overlapping segments on a street. The descriptive names for the features (like “Parking” or “Curb cut”) are there to help you keep track of which segment is which so you know which feature you intend to complete. 28 | 29 | It’s often more efficient to use this layering approach to map a street. 30 | 31 | Later on, back in the office, you will use the digitizer to define a “priority category” for each type of curb regulation. This is used in CurbLR to indicate which zones take precedence over others, meaning that they should be layered on top. 32 | 33 | ## FAQs 34 | 35 | ### How accurate is the CurbWheel? 36 | 37 | The measuring wheel itself is accurate to 0.1 metres. While no one walks in a perfectly straight line, variances from normal walking were very small; when we measured a street repeatedly, we did not experience variation of more than 0.1-0.2 metres for a city block, so the length of curb segments measured and their relative position to one another is very accurate. 38 | 39 | However, there is a transform applied to the street segments to account for the intersection length, which is not expected to be surveyed. See more info on this below. This can introduce some degree of error if the two intersections on either side of a block have very different lengths. In these cases, the curb feature lengths will be accurate but their position on the street may be slightly off. Even in these cases, the CurbWheel is accurate to within the length of a parking space. That seems to be the resolution that really matters given that this parking rule data would be used for planning, analysis, and navigation rather than highly precise tasks. 40 | 41 | ### Where should I start to roll the wheel? Do I need to start in the intersection to capture the full length? 42 | 43 | Start from the edge of the sidewalk. Mapping in the middle of an intersection is impractical and potentially unsafe. Since the length of a street is measured from intersection to intersection, this means that there will be small gaps on either end of the street that aren't surveyed. For example, for a 100m street, you may only roll 90m so your progress bar may not fully complete. This is expected. The wheel will process the data by taking the length that you measured and centering it on the middle of the street, so that there is a small gap on either end to account for the intersection width. 44 | 45 | ### Should I take photos of everything on a street? 46 | 47 | We recommend taking photos of of any asset that contains information about the regulation. For example, the color of paint and the text on signs and parking meters are all necessary to encode the regulation later. For parking meters in specific, you may need to take photographs from multiple angles to capture information about rates, times, and other payment info if it is not all visible from the same angle. You may but are not required to take photos of features like driveways or curb extensions, which don't contain any regulation information. Choosing not to take photographs of those assets can make surveying more efficient. 48 | 49 | ### What if the wheel is not connecting to the phone? 50 | 51 | If you've just turned on the Raspberry Pi, wait a few minutes and try again. If that doesn't work, power cycle the Pi. 52 | 53 | ### What if my surveyed length is shorter or longer than expected? 54 | The app will center the data you collected on the middle of the street, so that the offset is applied evenly to both ends of the street. If your surveyed length is significantly longer or shorter than expected, you will be prompted with a warning dialog. It's a good idea to double-check that you mapped the correct street; that's the most common cause for this event. If you are certain your data is correct, tap "Complete" to save the survey. Significantly shorter than expected streets will end up with larger gaps on either end, and significantly longer street features will be scaled proportionally in order to fit on the street. 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 SharedStreets 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PARTS.md: -------------------------------------------------------------------------------- 1 | 2 | # Parts list 3 | 4 | - [AdirPro Measuring Wheel](https://adirpro.com/product/digital-measuring-wheel-2/) sources: [TigerSupplies](https://www.tigersupplies.com/Products/Digital-Measuring-Wheel__ADI715-05.aspx) | [TigerSupplies via Amazon](https://www.amazon.com/AdirPro-Distance-Measuring-Commercial-Feet-Inch/dp/B0156WY3SG) 5 | - [Raspberry Pi Zero W](https://www.raspberrypi.org/products/raspberry-pi-zero-w/) sources: [PiShop](https://www.pishop.us/product/raspberry-pi-zero-w/) 6 | - [JST PH 4-wire connector Part# S4B-PH-K-S](https://www.jst-mfg.com/product/pdf/eng/ePH.pdf) sources: [DigiKey](https://www.digikey.com/product-detail/en/jst-sales-america-inc/S4B-PH-K-S-LF-SN/455-1721-ND/926628) | [SparkFun](https://www.sparkfun.com/products/9916) (can also be purchased from a local hobby shop) 7 | - USB A to micro USB cable (This is a common cable for charging phones and other devices. If you already have one, you can use that; there's no need to buy another) sources: [PiShop](https://www.pishop.us/product/usb-flat-cable-a-microb-orange/) | [Amazon](https://www.amazon.com/Micro-USB-to-Cable/dp/B004GETLY2) 8 | - 32GB MicroSD Card + Adapter (Be careful of [counterfeit SD cards](https://www.bunniestudios.com/blog/?page_id=1022)) sources: [PiShop](https://www.pishop.us/product/microsd-card-extreme-pro-32-gb-class-10-blank/) | [Amazon](https://www.amazon.com/gp/product/B06XWN9Q99/) 9 | - Portable battery pack with USB two USB ports (one for the Pi and one for your phone, if you already have a USB battery pack for your pone, no need to buy another) sources: [PiShop](https://www.pishop.us/product/compact-rechargeable-battery-for-raspberry-pi-20800mah/) | [Amazon](https://www.amazon.com/Portable-Charger-Anker-PowerCore-20100mAh/dp/B00X5RV14Y/ref=sr_1_3?keywords=anker+battery+pack&qid=1585267755&refinements=p_89%3AAnker&rnid=2528832011&sr=8-3) 10 | - [3D printed case to contain the Pi and replace the handle parts that were removed](https://www.thingiverse.com/thing:4286063). (If you do not have access to a 3D printer, you could DIY some padding for the Pi with duct tape, bubble wrap, etc) 11 | 12 | ## Optional: 13 | - [SD card reader](https://www.amazon.com/gp/product/B00OJ5WBUE/) (if you already have a SD card reader built into your laptop, it should work fine with the SD to MicroSD adapter that comes with most cards) 14 | 15 | 16 | ## Tools: 17 | - Philips-Head screwdriver 18 | - [optional] Tiny screwdriver for levering 19 | - Soldering iron suitable for electronics (has a small tip) 20 | - Solder wire 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Status update, 10 Sept 2020 2 | We've uncovered a bug in the testing image that's preventing data downloads from working. Because of this and other issues, we decided to overhaul the software to turn it into an iOS and Android-native app. This will make initial set-up and usage much easier, and let us solve the networking issues. We hope to have a test version of the app out in the next couple of weeks - stay tuned for more updates! 3 | 4 | # SharedStreets CurbWheel 5 | 6 | CurbWheel is an open source data collection tool that can be used to map a city's curb assets and regulations and create a standardized data ([CurbLR](https://www.curblr.org/)) feed. It combines the precision of a measuring wheel with the efficiency of a smartphone app. 7 | 8 | CurbWheel is built from a standard digital measuring wheel used by surveyors, which is hardwired to a Raspberry Pi computing device. This lets the wheel connect to a smartphone or computer over Bluetooth in order transmit measurements to a curb surveying app. After completing street surveying with the wheel and the app, users can process the data in a digitizer web app, producing a CurbLR feed. 9 | 10 | ![](/images/wheel_app_digitizer.png) 11 | *Initial prototype of the wheel, app, and digitizer* 12 | 13 | ## How it works 14 | 15 | To map a street, a user opens the surveying app on his/her phone, where he/she will see a map of the surrounding streets. The user taps to select a block face to map and begins to roll the wheel down the sidewalk -- snapping photos along the way to capture curb cuts, parking signage, fire hydrants, and physical assets that communicate curb regulations. 16 | 17 | The CurbWheel app keeps track of the block faces that were surveyed and processes all incoming measurement data; every asset that was marked in the app is geolocated and [linear referenced](https://medium.com/sharedstreets/how-the-sharedstreets-referencing-system-works-2097b0d61b52) to determine its position along the street. From this information, the CurbWheel creates a linear-referenced street segment ("regulatory geometry") for each regulation that was mapped, which is stored alongside the accompanying photographs. The field data creates the geometries and captures a rough categorization for the curb regulation (e.g. "parking zone"). 18 | 19 | Afterwards, back at the office, the user uploads the field data from the curb wheel accesses it through the "digitizer", a lightweight data entry interface. This lets the user completes the curb inventory data by adding essential details to each regulation (e.g. the zone is in effect from 9am-5pm, Monday to Friday).The digitizer enables the user to iterate through each curb segment mapped, view the associated photographs as a reference, and use this information to populate an attribute form for the curb segment. When this is finished, the user exports the curb inventory as a standardized [CurbLR](https://www.curblr.org/) feed. This feed can be viewed in GIS systems, shared directly with consumers, and/or added into an [interactive map](https://www.curblr.org/). 20 | 21 | The curb wheel provides an efficient and accurate pathway for city governments or others looking to collect curb inventory data and share it in a standardized (CurbLR) format. 22 | 23 | ## Using the CurbWheel 24 | 25 | 1. Build the wheel. See the [parts list](/PARTS.md) and [set up page](/SETUP.md) for instructions on how to build your own CurbWheel and load it with the necessary software. Reach out to [SharedStreets](mailto:info@sharedstreets.io) if you work for a government agency and you may need some help with this. 26 | 27 | 2. Download the app. Once the wheel is ready, you will download an Android or iOS app onto your phone and then go forth and survey your streets. 28 | 29 | 3. Use the CurbWheel to survey streets. Instructions, tips, and tricks for how to use the app are [here](HOWTOMAP.md). 30 | 31 | 4. Create a CurbLR feed from your survey data. To do this, we've created a lightweight data entry interface. Instructions and tips for using it are [here](HOWTODIGITIZE.md). 32 | 33 | ### Current software versions 34 | 35 | - Raspberry Pi software is [here](https://curblr-www.s3.amazonaws.com/wheel/images/curbwheel_image_bleno_r1.img.gz). The code used to create this image lives [here](https://github.com/sharedstreets/curb-wheel-ble). 36 | - Android and iOS app source code lives [here](https://github.com/sharedstreets/curb-wheel/tree/cordova-backend-switch). 37 | - Digitizer source code lives [here](https://github.com/sharedstreets/curbwheel-digitizer). 38 | 39 | 40 | ------ 41 | 42 | ## Acknowledgements 43 | 44 | Thanks to Kuan Butts for suggesting the wheel + API idea. We were also inspired by [TreeKit](http://treekit.org/)'s approach to mapping street trees in NYC. 45 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # Initial set-up 2 | 3 | How to build a CurbWheel from scratch. Covers hardware and software. 4 | 5 | ## Prepare the measuring wheel 6 | 7 | The measuring wheel works by using a ["rotary encoder"](https://howtomechatronics.com/tutorials/arduino/rotary-encoder-works-use-arduino/), which is a set of [magnets and sensors](https://i.imgur.com/QDmhP2q.jpg) in its shaft. As the wheel turns, one of the magnets passes a sensor every 0.1 metres rolled, and a signal is sent up to the circuit board. These signals are added (when rolling forwards) and subtracted (when rolling backwards) to keep track of the distance rolled. We will be taking the wheel apart and sending these measurement signals to the Raspberry Pi instead. 8 | 9 | 1\. Take the measuring wheel out of its packaging and place it on a work surface: 10 | 11 | 12 | 13 | 2\. Use a Philips-head screwdriver to remove the screws on the handle of the measuring wheel. There are 6 screws on the underside of the handle (2 of them are set deeply). Removing these will reveal the battery compartment. Remove the 2 AAA batteries. Then remove the additional 3 screws that are accessible from the battery compartment: 14 | 15 | 16 | 17 | 3\. Inside the handle is a circuit board with an LCD display screen on the back. Remove the 2 screws on this circuit board: 18 | 19 | 20 | 21 | 4\. The circuit board has two cables coming into it: a translucent ribbon cable with a black connector, and a JST cable that has four colored wires (red, yellow, black, green) with a white connector. 22 | 23 | 24 | 25 | The ribbon cable carries signals from the buttons on the handle to the circuit board. We don't need this functionality. Detach the ribbon cable from the board by pulling back gently on the cable. 26 | 27 | The JST cable carries measurement signals up from the shaft of the wheel. We will be routing these signals to the Raspberry Pi instead of the circuit board. Carefully remove the JST connector from the circuit board. This can be tricky; it helps to use a tiny screwdriver to push down on the two small tabs that look like indents on the surface of the connector. It may also help to use the screwdriver as a lever to gently force the connector out of its housing. Be very careful not to damage the connector or the wires when you do this. 28 | 29 | 30 | 31 | The wheel should now be in pieces, like so: 32 | 33 | 34 | 35 | 5\. Everything that isn't currently attached to the measuring wheel can be set aside; these parts aren't necessary. Place them in a ziploc bag or envelope for safe keeping. 36 | 37 | ## Assemble the Raspberry Pi 38 | 39 | 1\. The Raspberry Pi has a "top" and a "bottom" side. The "bottom" side of the Pi is flat and has the raspberry logo on it. The "top" side of the Pi has ports and other irregularities coming out of it. Place your Pi on a work surface with the top side facing up. Note that there is a 2x20 grid of "breakout holes" on one side of the Pi. 40 | 41 | 2\. Take the new JST cable (not the one attached to the wheel) and very gently remove the female end of the connector piece at the end of the cable: 42 | 43 | 44 | 45 | 3\. You will be positioning the female end of the JST connector so that its 4 metal pins fit into the breakout pin holes on the Pi. It is very important that you plug the connector into the correct holes. Note the alignment and positioning very carefully in the images below. Locate the correct holes and gently spread the 4 pins on the connector apart so that they will fit into the holes. This can be done manually, the pins are flexible.: 46 | 47 | 48 | 49 | When plugged in, the connector should look like this: 50 | 51 | 52 | 53 | 4\. Flip the Pi over so that the bottom side is facing up. Triple-check to ensure you have plugged the connector into the correct holes. Solder the 4 pins from the connector cable to the bottom side of the board. Each individual pin needs to have a soldered connection to the gold circle around its hole. Be very careful not to solder two pins to one another or to another gold circle on the board. 54 | 55 | 56 | 57 | 5\. Plug the JST cable on the wheel into the JST connector that you just soldered onto the Pi. It should snap into place: 58 | 59 | 60 | 61 | 62 | ## Prepare the Micro SD card 63 | 64 | 1. Download the Etcher software app for your computer and install it. You will use this to set up the microSD card. 65 | 66 | 2. Download the latest version of the software to install on your microSD. Unzip the file if it does not automatically unzip once downloaded. (For developers: The software used to create this image lives [here](https://github.com/sharedstreets/curb-wheel-ble).) 67 | 68 | 3. Insert your microSD into your card reader and open Etcher. Select the unzipped file as the image. Select your microSD card as the target. It is important to point this at the microSD card as this could rewrite your hard drive if the wrong location is selected. 69 | 70 | 71 | 72 | Once this is set up properly, write the file to the microSD by selecting 'Flash!'. This step may prompt you to enter a password; this is your computer account password. 73 | 74 | 4. Eject and remove the microSD card. Insert your microSD card into the reader on your Raspberry Pi. 75 | 76 | 77 | 5. Plug your Pi into a power source (such as a power bank that you would use to recharge your phone, or a charging cable plugged into the wall) using the microUSB port labelled "PWR in". If you're using a power bank, make sure it's turned on. You should then see a green blinking light on your Pi in a heartbeat pattern to indicate that it is working. 78 | 79 | ## Protect the Pi 80 | 81 | The Raspberry Pi should be protected from damage. If you're able to 3D-print a [protective case (files are included here)](https://www.thingiverse.com/thing:4286063), this can be screwed into place with a cover that snaps on. This will protect it if you drop your wheel. If you don't have access to a 3D printer, you could improvise a case with bubble wrap, duct tape, and a bit of cardboard or similar. You could also print an addition or tape your battery pack to the shaft of the wheel, but be careful that this doens't make it too top-heavy and unstable to use the kickstand. 82 | -------------------------------------------------------------------------------- /bench/extract.bench.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const Graph = require("../src/graph"); 3 | 4 | async function cycle(fixture) { 5 | let start = Date.now(); 6 | 7 | let graph = new Graph(); 8 | await graph.extract(fixture); 9 | 10 | let stop = Date.now(); 11 | return stop - start; 12 | } 13 | 14 | async function bench(fixture, description) { 15 | console.log(path.basename(fixture)); 16 | console.log(description); 17 | console.log("---"); 18 | 19 | let cycles = 10; 20 | let total = 0; 21 | for (let i = 0; i < cycles; i++) { 22 | let time = await cycle(fixture); 23 | console.log(i + 1, "/", cycles, ": ", time, "ms"); 24 | total += time; 25 | } 26 | 27 | console.log("average: ", (total / cycles).toFixed(4) + " ms\n"); 28 | } 29 | 30 | async function run() { 31 | await bench( 32 | path.join(__dirname, "../test/fixtures/oakland.osm.pbf"), 33 | "small neighborhood transport-only hot extract" 34 | ); 35 | await bench( 36 | path.join(__dirname, "../test/fixtures/honolulu.osm.pbf"), 37 | "medium full city nextzen metro extract" 38 | ); 39 | await bench( 40 | path.join(__dirname, "../test/fixtures/dc.osm.pbf"), 41 | "medium full city transport-only hot extract" 42 | ); 43 | await bench( 44 | path.join(__dirname, "../test/fixtures/nyc.osm.pbf"), 45 | "large full city transport-only hot extract" 46 | ); 47 | } 48 | 49 | run(); 50 | -------------------------------------------------------------------------------- /config/wifi.json: -------------------------------------------------------------------------------- 1 | {"network":"NETGEAR44","password":"largetulip413","mode":"wifi"} -------------------------------------------------------------------------------- /config/wpa_supplicant.conf: -------------------------------------------------------------------------------- 1 | ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 2 | update_config=1 3 | country=us 4 | 5 | network={ 6 | ssid="undefined" 7 | psk="undefined" 8 | } -------------------------------------------------------------------------------- /config/wpa_supplicant.conf.template: -------------------------------------------------------------------------------- 1 | ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 2 | update_config=1 3 | country=us 4 | 5 | network={ 6 | ssid="[NAME OF WIFI NETWORK]" 7 | psk="[WIFI NETWORK PASSWORD]" 8 | } -------------------------------------------------------------------------------- /data-model.md: -------------------------------------------------------------------------------- 1 | # Data model and hierarchy 2 | 3 | ## Input data hierarchy 4 | 5 | - Area of interest 6 | - Deployment 7 | - Background map tiles 8 | - OpenStreetMap data 9 | - Graph 10 | - Geometries (a street, regardless of direction) 11 | - Linear references (a street, in one direction) 12 | - Configuration properties 13 | - Feature type 14 | - Geometry type 15 | - Intersection offset length 16 | 17 | ### Area of interest 18 | The area of interest is the area that a person wants to survey with one or more CurbWheels. 19 | 20 | ### Deployment 21 | When someone prepares to survey an area of interest, they will create a "deployment", which includes the files that need to be loaded onto each CurbWheel. The deployment files include: 22 | 23 | - OpenStreetMap data for the area, in [PBF format](https://wiki.openstreetmap.org/wiki/PBF_Format). The PBF must contain street data (i.e. ways tagged in OpenStreetMap as `highway=*`). It may also contain other features from OpenStreetMap (e.g. building outlines); these are unnecessary and will be ignored, but they will not cause errors. 24 | - Background map tiles, used for display purposes. These are in [MBTiles](https://docs.mapbox.com/help/glossary/mbtiles/) format. 25 | 26 | These input data can be created through the [HOT Export Tool](https://export.hotosm.org/en/v3/) or other means. 27 | 28 | Once the deployment is loaded, software on the Raspberry Pi will process the OpenStreetMap street data to create a graph network of streets which have associated [SharedStreets linear referencing](https://sharedstreets.io/how-the-sharedstreets-referencing-system-works/) properties (such as a reference ID and length). 29 | 30 | When setting up a deployment, users will also be prompted to provide configuration properties, discussed below. 31 | 32 | ### Graph 33 | The graph is a network of all the streets in the area of interest. Each street in the graph has associated [SharedStreets linear referencing](https://sharedstreets.io/how-the-sharedstreets-referencing-system-works/) properties (such as a reference ID and length). The graph is created by the software on the Raspberry Pi, using the OpenStreetMap data included in the deployment. 34 | 35 | ### Street geometries 36 | A street is a segment of road, from one intersection to the next, which includes travel in both directions (where applicable). In the SharedStreets linear referencing system, this is referred to as a "geometry". 37 | 38 | Each geometry has a corresponding, unique SharedStreets geometry ID which is determined when the graph is created. 39 | 40 | ### Street linear references 41 | Each street geometry is made up of one or more linear references, which account for direction of travel. A one-way street has one reference. A two-way street has two references. 42 | 43 | Each reference has: 44 | - A corresponding, unique SharedStreets reference ID which is determined when the graph is created 45 | - An expected length. The length of each reference is determined when the graph is created, based on the reference's geographic coordinates. 46 | 47 | Because each street reference has directionality, we are able to refer to objects as being on the left or right side of the street, relative to direction of travel. 48 | 49 | ### Configuration properties 50 | There are two other pieces of information needed to set up the deployment for the area of interest. These include: 51 | 52 | - Feature types. Buttons will appear in the app to help categorize the type of features that are being surveyed. These are the "feature types". For example, a user may use categories like "parking zone", "curb paint", "fire hydrant", or "tree bed". Each feature type must have an associated geometry type, either points (named "point-along" since they are points along a linear reference) or lines (named "span-along" since they are spans along a linear reference). For example, a parking zone has a beginning point and an end point, and it is therefore of type "span-along". A fire hydrant is of type "point-along". 53 | - The intersection offset length. Each street reference has a length, measured from the center of the start intersection to the center of the end intersection. When users roll the CurbWheel down the street, it will count upwards from zero, but they are not beginning from the center of the start intersection - there is an offset between the intersection and the beginning of the curb. We estimate this offset in order to calculate an "expected length" of the survey. When setting up a deployment, users are asked for an average number of lanes. This number is used to calculate an offset that will be applied (symmetrically) to the beginning and end of each street reference in order to determine the expected length. For example, a street reference may have a length of 100 metres. The user has given an offset of 4 travel lanes in each intersection. We estimate that each lane is roughly 3.048 metres (10 feet) wide. This means that we expect the wheel to start rolling when the user is 12.174 metres into the street reference, and to finish rolling when the user is 12.174 metres short of the end of the street reference. We factor in the offset to adjust the expected length to be 75.616 metres instead of 100 metres. Expected lengths are used to help keep track of relative progress when rolling along the street; they are estimates and need not be precise or accurate for every street reference. 54 | 55 | ## Survey data hierarchy (client-side) 56 | 57 | - Survey 58 | - Features 59 | - Images 60 | 61 | 62 | ### Survey 63 | 64 | A survey is made up of a list of features that were captured during one "curb walk" down the street, in a specific direction. In the app, when a user taps on a street on the map and selects which direction they are going, this begins the survey. When a user marks the street as complete and returns to the map view on the app, this ends the survey. A survey must contain a timestamp in epoch milliseconds, a SharedStreets reference ID, the side of street surveyed ("right" or "left"), a surveyed distance in meters, and a list of surveyed features. 65 | 66 | #### Example 67 | 68 | ```json 69 | { 70 | "created_at": 1588833685540, 71 | "shst_ref_id": "6mjqqv7YNsp4541DmrrRbV", 72 | "side_of_street": "right", 73 | "surveyed_distance": "441.6", 74 | "features": [ 75 | "..." 76 | ] 77 | } 78 | ``` 79 | 80 | ### Feature 81 | 82 | A feature is a set of information about an entity on the street, such as a "no parking" zone, a fire hydrant, a bus stop, or a driveway. A feature must contain a valid label and linear geometry. It may contain a list of associated images, including their url, and linear geometry properties. 83 | 84 | #### Example 85 | 86 | ```json 87 | { 88 | "label": "no parking", 89 | "geometry": { 90 | "type": "Span", 91 | "distances": [220.5, 405.7] 92 | }, 93 | "images": [ 94 | { 95 | "url": "https://i.imgur.com/Fl8HQpU.jpg", 96 | "geometry":{ 97 | "type": "Position", 98 | "distance": 214.8 99 | } 100 | }, 101 | { 102 | "url": "https://i.imgur.com/dNE2Hlh.jpg", 103 | "geometry":{ 104 | "type": "Position", 105 | "distance": 311.4 106 | } 107 | }, 108 | { 109 | "url": "https://i.imgur.com/Yhb2bJZ.jpg", 110 | "geometry":{ 111 | "type": "Position", 112 | "distance": 405.7 113 | } 114 | } 115 | ] 116 | } 117 | ``` 118 | 119 | ### Geometry 120 | 121 | There are two types of valid linear reference geometry in the CurbWheel data model, `Position` and `Span`. Geometries are similar to GeoJSON, but they represent linear offsets from the beginning of a [LineString](https://tools.ietf.org/html/rfc7946#section-3.1.4). 122 | 123 | #### Position 124 | 125 | A position is a fixed point along a LineString. It is described as a distance offset from the start of the LineString. `distance` must be a numeric type. The unit is meters. 126 | 127 | ##### Example 128 | 129 | ```json 130 | { 131 | "type": "Position", 132 | "distance": 264.8 133 | } 134 | ``` 135 | 136 | #### Span 137 | 138 | A span is a subsection along a LineString. It is described as a pair of distance offsets from the start of the LineString. `distances` must contain exactly 2 elements. The unit is meters. 139 | 140 | ##### Example 141 | 142 | ```json 143 | { 144 | "type": "Span", 145 | "distances": [264.8, 287.2] 146 | } 147 | ``` 148 | 149 | 150 | ## Data storage and export 151 | 152 | Data is stored on the Raspberry Pi and may be exported from the CurbWheel in three formats: 153 | - GeoJSON. This is a plain, flat data format which can be exported into GIS systems or manipulated in other ways. All properties are included. The coordinates for each point or linestring feature are taken from its adjusted location, in order to account for intersection offsets; this is the more accurate positioning along the street. Since all data are retained, users could refine or remove this adjustment if desired. 154 | - CurbLR. This is a JSON file created according to the [CurbLR curb regulation data specification](https://github.com/sharedstreets/curblr), though most fields will be empty. The JSON file will contain only linestring GeoJSON features. Feature categories are included as CurbLR `activity` or `marker` properties. 155 | - Asset data. This is a GeoJSON file with point and linestring features, created according to the [Open Curbs Asset Data Specification](https://www.coord.com/hubfs/Coord_November2019%20Files/PDF/8b1277_8e32c9463b3743b7833b9c1e82f0b558.pdf?hsLang=en). Feature categories are included as `asset type` properties. 156 | 157 | We recommend processing the data using the Curb Digitizer, but have provided these data formats to allow multiple options for users who may want to pursue other paths. 158 | 159 | ## Data processing and final output 160 | 161 | The Curb Digitizer can be used to process the features and associated images into both asset and regulation data, which can be exported into the [Open Curbs Asset Data Specification](https://www.coord.com/hubfs/Coord_November2019%20Files/PDF/8b1277_8e32c9463b3743b7833b9c1e82f0b558.pdf?hsLang=en) and [CurbLR](https://github.com/sharedstreets/curblr) regulation data specification formats. 162 | -------------------------------------------------------------------------------- /images/CurbWheelTestScreen.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/CurbWheelTestScreen.jpg -------------------------------------------------------------------------------- /images/curb-wheel-3d-base.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/curb-wheel-3d-base.jpg -------------------------------------------------------------------------------- /images/curb-wheel-3d-cap.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/curb-wheel-3d-cap.jpg -------------------------------------------------------------------------------- /images/digitizer1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/digitizer1.png -------------------------------------------------------------------------------- /images/digitizer3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/digitizer3.png -------------------------------------------------------------------------------- /images/digitizer4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/digitizer4.png -------------------------------------------------------------------------------- /images/etcher_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/etcher_example.png -------------------------------------------------------------------------------- /images/howtomap1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/howtomap1.png -------------------------------------------------------------------------------- /images/howtomap2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/howtomap2.png -------------------------------------------------------------------------------- /images/howtomap3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/howtomap3.png -------------------------------------------------------------------------------- /images/howtomap4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/howtomap4.png -------------------------------------------------------------------------------- /images/insertSD.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/insertSD.jpg -------------------------------------------------------------------------------- /images/overlaptrick1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/overlaptrick1.png -------------------------------------------------------------------------------- /images/overlaptrick2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/overlaptrick2.png -------------------------------------------------------------------------------- /images/pi_1.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_1.JPG -------------------------------------------------------------------------------- /images/pi_2.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_2.JPG -------------------------------------------------------------------------------- /images/pi_3.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_3.JPG -------------------------------------------------------------------------------- /images/pi_4.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_4.JPG -------------------------------------------------------------------------------- /images/pi_5.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_5.JPG -------------------------------------------------------------------------------- /images/pi_parity_1.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_parity_1.JPG -------------------------------------------------------------------------------- /images/pi_parity_2.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/pi_parity_2.JPG -------------------------------------------------------------------------------- /images/wheel_1.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_1.JPG -------------------------------------------------------------------------------- /images/wheel_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_2.png -------------------------------------------------------------------------------- /images/wheel_3.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_3.JPG -------------------------------------------------------------------------------- /images/wheel_4.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_4.JPG -------------------------------------------------------------------------------- /images/wheel_5.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_5.JPG -------------------------------------------------------------------------------- /images/wheel_6.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_6.JPG -------------------------------------------------------------------------------- /images/wheel_app_digitizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/images/wheel_app_digitizer.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "curb-wheel", 3 | "version": "1.0.0", 4 | "description": "Curb mapping wheel", 5 | "bin": "./src/server.js", 6 | "scripts": { 7 | "start": "sudo sh startup.sh && node ./src/server.js", 8 | "simulator": "sh startup-simulator.sh && node --trace-warnings ./src/server.js", 9 | "test": "tap -R spec ./test/*.test.js", 10 | "lint": "prettier --write **/*.js", 11 | "bench": "node --max-old-space-size=512 ./bench/extract.bench.js" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/sharedstreets/curb-wheel.git" 16 | }, 17 | "author": "Emily Eros", 18 | "contributors": [ 19 | "Emily Eros", 20 | "Kevin Webb", 21 | "Mollie McArdle", 22 | "Morgan Herlocker", 23 | "Peter Liu" 24 | ], 25 | "license": "MIT", 26 | "bugs": { 27 | "url": "https://github.com/sharedstreets/curb-wheel/issues" 28 | }, 29 | "homepage": "https://github.com/sharedstreets/curb-wheel#readme", 30 | "dependencies": { 31 | "@mapbox/graph-normalizer": "^3.1.2", 32 | "@mapbox/tile-cover": "^3.0.2", 33 | "@mapbox/tilebelt": "^1.0.1", 34 | "@turf/turf": "^5.1.6", 35 | "archiver": "^4.0.1", 36 | "bulma": "^0.8.1", 37 | "copy-dir": "^1.3.0", 38 | "express": "^4.17.1", 39 | "express-fileupload": "^1.1.9", 40 | "level": "^6.0.1", 41 | "osm-pbf-parser": "^2.3.0", 42 | "rbush": "^3.0.1", 43 | "request": "^2.88.2", 44 | "sharedstreets": "^0.15.1", 45 | "through2": "^3.0.1" 46 | }, 47 | "devDependencies": { 48 | "prettier": "^2.0.2", 49 | "tap": "^14.10.7" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /python/wheel-simulator.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import random 4 | import decimal 5 | 6 | COUNTER_OUTPUT_PATH = "ram/counter.txt" 7 | 8 | counter = 0 9 | 10 | # state change handler 11 | def incrementCounter(): 12 | global counter 13 | counter += decimal.Decimal(random.randrange(1, 10)) 14 | with open(COUNTER_OUTPUT_PATH, 'w') as fileOut: 15 | fileOut.write(str(counter)) 16 | 17 | print "starting wheel counter simulator" 18 | 19 | # run code 20 | while True: 21 | time.sleep(1) 22 | incrementCounter() 23 | -------------------------------------------------------------------------------- /python/wheel.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import time 3 | import RPi.GPIO as GPIO 4 | 5 | # disable warnings 6 | GPIO.setwarnings(False) 7 | 8 | # use Pi BCM mode for pin numbers 9 | GPIO.setmode(GPIO.BCM) 10 | 11 | # hall effect pins 12 | channel1 = 19 13 | channel2 = 26 14 | 15 | # sensor power pin 16 | powerPin = 13 17 | inputPins = [channel1, channel2] 18 | 19 | # set input state and pull up resistor on input pins so the values don't float 20 | GPIO.setup(inputPins, GPIO.IN, pull_up_down=GPIO.PUD_UP) 21 | 22 | 23 | GPIO.setup(powerPin, GPIO.OUT) 24 | 25 | # power up sensors via sensor pin (Pi can source ~10mA per io pin -- should be more than enough for hall application) 26 | GPIO.output(powerPin, GPIO.HIGH) 27 | 28 | # zero counter global 29 | counter = 0 30 | 31 | DEBOUNCE_TIME_MS = 100 32 | 33 | # simplifed string values of sensor states "[channel1],[channel2]" 34 | COUNT_STATE = "1,1" 35 | FORWARD_STATE = "0,1" 36 | BACKWARD_STATE = "1,0" 37 | 38 | COUNT_BACKWARDS = False 39 | 40 | 41 | COUNTER_OUTPUT_PATH = "ram/counter.txt" 42 | 43 | previousState = "" 44 | 45 | # state change handler 46 | def stateChange(channel): 47 | global previousState, counter 48 | channel2State = GPIO.input(channel1) 49 | channel1State = GPIO.input(channel2) 50 | 51 | # create sensor state string 52 | currentState = str(channel1State) + "," + str(channel2State) 53 | 54 | 55 | # compare current sensor state to previous to determine wheel rotation direction 56 | if previousState == "": 57 | previousState = currentState 58 | elif currentState == COUNT_STATE: 59 | if previousState == FORWARD_STATE: 60 | counter += 1 61 | elif previousState == BACKWARD_STATE and COUNT_BACKWARDS: 62 | counter -= 1 63 | 64 | previousState = "" 65 | 66 | with open(COUNTER_OUTPUT_PATH, 'w') as fileOut: 67 | fileOut.write(str(counter)) 68 | 69 | 70 | # debounced state change events from hall effect sensor input pins 71 | GPIO.add_event_detect(channel1, GPIO.RISING, callback=stateChange, bouncetime=DEBOUNCE_TIME_MS) 72 | GPIO.add_event_detect(channel2, GPIO.RISING, callback=stateChange, bouncetime=DEBOUNCE_TIME_MS) 73 | 74 | print "starting wheel counter" 75 | 76 | # run code 77 | while True: 78 | time.sleep(1) 79 | -------------------------------------------------------------------------------- /setup-ap.sh: -------------------------------------------------------------------------------- 1 | # backup AP version of /etc/dhcpcd.conf for config switching 2 | sudo cp /etc/dhcpcd.conf /etc/raspap/backups/dhcpcd.conf.ap -------------------------------------------------------------------------------- /setup-ramdisk-simulator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "starting ramdisk simulator" 4 | mkdir -p ./ram -------------------------------------------------------------------------------- /setup-ramdisk.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "starting ramdisk" 3 | sudo mkdir -p ./ram 4 | sudo mount -t tmpfs -o size=1m tmpfs ./ram -------------------------------------------------------------------------------- /setup-tileserver-simulator.sh: -------------------------------------------------------------------------------- 1 | echo "installing tileserver" 2 | npm install -g tileserver-gl-light 3 | -------------------------------------------------------------------------------- /setup-tileserver.sh: -------------------------------------------------------------------------------- 1 | # installs and configures tile server for gl vector tiles 2 | 3 | # install sqlite3 dev libs for source build of node driver 4 | sudo apt-get install libsqlite3-dev 5 | 6 | # relax /usr/local permissions for global npm install 7 | sudo chmod -R 777 /usr/local/ 8 | 9 | # global install of tile server node module with sqlite driver from source 10 | npm install --build-from-source --sqlite=/usr/local -g tileserver-gl-light -------------------------------------------------------------------------------- /setup-wifi.sh: -------------------------------------------------------------------------------- 1 | # edit config/wpa_supplicant.conf using config/wpa_supplicant.conf.template as guide 2 | # file to include local network name and password 3 | 4 | sudo cp config/wpa_supplicant.conf /etc/wpa_supplicant/wpa_supplicant.conf 5 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | # curl https://raw.githubusercontent.com/sharedstreets/curb-wheel/master/setup.sh | sh 2 | 3 | # UPDATE DEPENDENCIES 4 | sudo apt update 5 | sudo apt upgrade 6 | 7 | # INSTALL NODE & NPM 8 | sudo apt install nodejs npm 9 | 10 | # SETUP AP MODE (default production deployment) 11 | sh setup-ap.sh 12 | 13 | # SETUP LOCAL WIFI MODE (development deployment) 14 | # sh setup-wifi.sh 15 | 16 | -------------------------------------------------------------------------------- /src/graph.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const promisify = require("util").promisify; 3 | const level = require("level"); 4 | const cover = require("@mapbox/tile-cover"); 5 | const tilebelt = require("@mapbox/tilebelt"); 6 | const turf = require("@turf/turf"); 7 | const through2 = require("through2"); 8 | const parser = require("osm-pbf-parser"); 9 | const shst = require("sharedstreets"); 10 | const normalizer = require("@mapbox/graph-normalizer"); 11 | const RBush = require("rbush"); 12 | 13 | const readFileAsync = promisify(fs.readFile); 14 | const writeFileAsync = promisify(fs.writeFile); 15 | 16 | function Graph() { 17 | this.streets = []; 18 | this.refs = new Map(); 19 | this.bounds = [-Infinity, -Infinity, Infinity, Infinity]; 20 | this.center = [0, 0]; 21 | this.index = {}; 22 | this.surveys = new Map(); 23 | this.loaded = false; 24 | } 25 | 26 | Graph.prototype.query = async function (bbox) { 27 | return this.index 28 | .search({ 29 | minX: bbox[0], 30 | minY: bbox[1], 31 | maxX: bbox[2], 32 | maxY: bbox[3], 33 | }) 34 | .map((k) => { 35 | return this.streets[k.id]; 36 | }); 37 | }; 38 | 39 | Graph.prototype.save = async function (file) { 40 | let copy = {}; 41 | copy.streets = this.streets; 42 | copy.refs = {}; 43 | for (const [key, value] of this.refs) { 44 | copy.refs[key] = value; 45 | } 46 | copy.bounds = this.bounds; 47 | copy.center = this.center; 48 | copy.index = this.index.toJSON(); 49 | copy.surveys = {}; 50 | for (const [key, value] of this.surveys) { 51 | copy.surveys[key] = value; 52 | } 53 | 54 | await writeFileAsync(file, JSON.stringify(copy)); 55 | }; 56 | 57 | Graph.prototype.load = async function (file) { 58 | let raw = (await readFileAsync(file)).toString(); 59 | let data = JSON.parse(raw); 60 | this.streets = data.streets; 61 | this.refs = new Map(); 62 | for (const key of Object.keys(data.refs)) { 63 | this.refs.set(key, data.refs[key]); 64 | } 65 | this.bounds = data.bounds; 66 | this.center = data.center; 67 | this.index = new RBush().fromJSON(data.index); 68 | this.surveys = new Map(); 69 | for (const key of Object.keys(data.surveys)) { 70 | this.surveys.set(key, data.surveys[key]); 71 | } 72 | this.loaded = true; 73 | }; 74 | 75 | Graph.prototype.extract = async function (pbf) { 76 | return new Promise((resolve, reject) => { 77 | let mapMetadata = { 78 | totalX: 0, 79 | totalY: 0, 80 | count: 0, 81 | minX: Infinity, 82 | minY: Infinity, 83 | maxX: -Infinity, 84 | maxY: -Infinity, 85 | }; 86 | let ways = []; 87 | let nodes = new Map(); 88 | 89 | const parse = parser(); 90 | 91 | fs.createReadStream(pbf) 92 | .pipe(parse) 93 | .pipe( 94 | through2.obj((items, enc, next) => { 95 | for (let item of items) { 96 | if (item.type === "node") { 97 | nodes.set(item.id, item); 98 | } else if ( 99 | item.type === "way" && 100 | item.tags.highway && // must have a highway tag 101 | // must be one of the following highway types. service roads are only included if they have a name (SF has lots of residential streets mapped as named alleys) 102 | (item.tags.highway === "motorway" || 103 | item.tags.highway === "trunk" || 104 | item.tags.highway === "primary" || 105 | item.tags.highway === "secondary" || 106 | item.tags.highway === "tertiary" || 107 | item.tags.highway === "unclassified" || 108 | item.tags.highway === "residential" || 109 | item.tags.highway === "living_street" || 110 | (item.tags.highway === "service" && item.tags.name)) && 111 | 112 | // we removed unnamed service roads bc they were cluttering the graph and causing confusion, and are rarely needed for mapping. if you want to add service roads back in, remove the item.tags.name argument above and uncomment out the code below: 113 | 114 | // filter the following special service road types 115 | // (!item.tags.service || 116 | // !( 117 | // item.tags.service === "parking" || 118 | // item.tags.service === "driveway" || 119 | // item.tags.service === "drive-through" || 120 | // item.tags.service === "parking_aisle" 121 | // )) && 122 | item.refs.length >= 2 123 | ) { 124 | ways.push(item); 125 | } 126 | } 127 | next(); 128 | }) 129 | ) 130 | .on("finish", () => { 131 | ways = ways.map((way) => { 132 | let coordinates = []; 133 | for (let ref of way.refs) { 134 | const node = nodes.get(ref); 135 | coordinates.push([node.lon, node.lat]); 136 | 137 | mapMetadata.totalX += node.lon; 138 | mapMetadata.totalY += node.lat; 139 | mapMetadata.count++; 140 | if (node.lon < mapMetadata.minX) mapMetadata.minX = node.lon; 141 | if (node.lat < mapMetadata.minY) mapMetadata.minY = node.lat; 142 | if (node.lon > mapMetadata.maxX) mapMetadata.maxX = node.lon; 143 | if (node.lat > mapMetadata.maxY) mapMetadata.maxY = node.lat; 144 | } 145 | let properties = way.tags; 146 | properties.id = way.id; 147 | properties.refs = way.refs; 148 | let line = turf.lineString(coordinates, properties); 149 | 150 | return line; 151 | }); 152 | 153 | ways = normalizer.splitWays(ways); 154 | ways = normalizer.mergeWays(ways); 155 | 156 | this.refs = new Map(); 157 | let i = 0; 158 | for (let way of ways) { 159 | way.properties.forward = shst.forwardReference(way).id; 160 | way.properties.back = shst.backReference(way).id; 161 | way.properties.distance = turf.length(way, { units: "meters" }); 162 | 163 | this.refs.set(way.properties.forward, i); 164 | this.refs.set(way.properties.back, i); 165 | i++; 166 | } 167 | 168 | this.streets = ways; 169 | this.center = [ 170 | mapMetadata.totalX / mapMetadata.count, 171 | mapMetadata.totalY / mapMetadata.count, 172 | ]; 173 | this.bounds = [ 174 | mapMetadata.minX, 175 | mapMetadata.minY, 176 | mapMetadata.maxX, 177 | mapMetadata.maxY, 178 | ]; 179 | 180 | // build spatial index 181 | let index = new RBush(); 182 | let k = 0; 183 | for (let street of this.streets) { 184 | let bbox = turf.bbox(street); 185 | index.insert({ 186 | minX: bbox[0], 187 | minY: bbox[1], 188 | maxX: bbox[2], 189 | maxY: bbox[3], 190 | id: k, 191 | }); 192 | k++; 193 | } 194 | this.index = index; 195 | this.surveys = new Map(); 196 | 197 | return resolve(this); 198 | }); 199 | }); 200 | }; 201 | 202 | module.exports = Graph; 203 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const express = require("express"); 4 | const fileUpload = require("express-fileupload"); 5 | const bodyParser = require("body-parser"); 6 | const rimraf = require("rimraf"); 7 | const mkdirp = require("mkdirp"); 8 | const child_process = require("child_process"); 9 | const turf = require("@turf/turf"); 10 | const archiver = require("archiver"); 11 | const copydir = require("copy-dir"); 12 | const Graph = require("./graph"); 13 | 14 | async function main() { 15 | return new Promise(async (resolve, reject) => { 16 | let app = express(); 17 | 18 | let ids = { 19 | feature: 0, 20 | image: 0, 21 | }; 22 | 23 | app.use(fileUpload()); 24 | 25 | app.use(bodyParser.json()); 26 | app.use( 27 | bodyParser.urlencoded({ 28 | extended: true, 29 | }) 30 | ); 31 | 32 | // constants 33 | const PORT = 8081; 34 | const PBF = path.join(__dirname, "../extract.osm.pbf"); 35 | const GRAPH = path.join(__dirname, "../graph.json"); 36 | const MBTILES = path.join(__dirname, "../extract.mbtiles"); 37 | const IMAGES = path.join(__dirname, "../static/images/survey"); 38 | const UNITS = { units: "meters" }; 39 | 40 | // application state 41 | app.state = {}; 42 | 43 | // debug 44 | app.state.graph = new Graph(); 45 | 46 | if (fs.existsSync(GRAPH)) { 47 | console.log("Found graph."); 48 | await app.state.graph.load(GRAPH); 49 | } else { 50 | console.log("No graph found."); 51 | } 52 | 53 | // setup static file server 54 | app.use("/static", express.static(path.join(__dirname, "../static"))); 55 | app.use( 56 | "/static/images", 57 | express.static(path.join(__dirname, "../static/images")) 58 | ); 59 | mkdirp.sync(IMAGES); 60 | 61 | app.get("/", async (req, res) => { 62 | if (fs.existsSync(GRAPH)) { 63 | let template = ( 64 | await fs.promises.readFile( 65 | path.join(__dirname, "../templates/index.html") 66 | ) 67 | ).toString(); 68 | 69 | template = template 70 | .split("{{bounds}}") 71 | .join(JSON.stringify(app.state.graph.bounds)); 72 | 73 | res.send(template); 74 | } else { 75 | // HTTP Status - 412 Precondition Failed 76 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/412 77 | res.status(412).redirect("/admin"); 78 | } 79 | }); 80 | 81 | app.get("/counter", async (req, res) => { 82 | let counterValue = parseInt( 83 | ( 84 | await fs.promises.readFile(path.join(__dirname, "../ram/counter.txt")) 85 | ).toString() 86 | ); 87 | 88 | res.json({ counter: counterValue }); 89 | }); 90 | 91 | app.get("/query", async (req, res) => { 92 | let streets = await app.state.graph.query([ 93 | +req.query.minX, 94 | +req.query.minY, 95 | +req.query.maxX, 96 | +req.query.maxY, 97 | ]); 98 | res.send({ 99 | type: "FeatureCollection", 100 | features: streets, 101 | }); 102 | }); 103 | 104 | app.post("/pbf", async (req, res) => { 105 | if (!req.files || Object.keys(req.files).length === 0) { 106 | return res.status(400).send("No pbf file was uploaded."); 107 | } 108 | 109 | let pbf = req.files.pbf; 110 | 111 | pbf.mv(PBF, async (err) => { 112 | if (err) { 113 | return res.status(500).send(err); 114 | } 115 | 116 | // extract pbf and build street database 117 | app.state.graph = new Graph(); 118 | app.state.graph = await app.state.graph.extract(PBF); 119 | await fs.promises.unlink(PBF); 120 | if (fs.existsSync(GRAPH)) { 121 | await fs.promises.unlink(GRAPH); 122 | } 123 | 124 | await app.state.graph.save(GRAPH); 125 | 126 | res.status(200).send("Extract complete."); 127 | }); 128 | }); 129 | 130 | app.post("/mbtiles", async (req, res) => { 131 | if (!req.files || Object.keys(req.files).length === 0) { 132 | return res.status(400).send("No mbtiles file was uploaded."); 133 | } 134 | 135 | let mbtiles = req.files.mbtiles; 136 | 137 | mbtiles.mv(MBTILES, async (err) => { 138 | if (err) { 139 | return res.status(500).send(err); 140 | } 141 | 142 | res.status(200).send("Tiles upload complete."); 143 | }); 144 | }); 145 | 146 | app.post("/photo", async (req, res) => { 147 | if (!req.files || Object.keys(req.files).length === 0) { 148 | return res.status(400).send("No image file was uploaded."); 149 | } 150 | 151 | let image = req.files.image; 152 | let ext = path.extname(req.files.image.name); 153 | let name = uuid() + ext; 154 | let imagePath = path.join(IMAGES, "/" + name); 155 | 156 | image.mv(imagePath, async (err) => { 157 | if (err) { 158 | return res.status(500).send(err); 159 | } 160 | 161 | res.status(200).send("/static/images/survey/" + name); 162 | }); 163 | }); 164 | 165 | app.post("/reset-surveys", async (req, res) => { 166 | app.state.graph.surveys = new Map(); 167 | 168 | rimraf.sync(IMAGES); 169 | mkdirp.sync(IMAGES); 170 | 171 | await app.state.graph.save(GRAPH); 172 | 173 | res.status(200).redirect("/admin"); 174 | }); 175 | 176 | app.get("/export.zip", async (req, res) => { 177 | let spans = []; 178 | let positions = []; 179 | let images = []; 180 | let spanPoints = []; 181 | let spanAndPositionPoints = []; 182 | 183 | for (let [ref, surveys] of app.state.graph.surveys) { 184 | if (!app.state.graph.refs.has(ref)) { 185 | throw new Error("Surveyed street ref not found: ", ref); 186 | } 187 | 188 | let street = app.state.graph.streets[app.state.graph.refs.get(ref)]; 189 | 190 | // flip geometry if survey is back ref 191 | if (ref === street.properties.back) { 192 | street.geometry.coordinates.reverse(); 193 | } 194 | 195 | for (let survey of surveys) { 196 | let startOffset = 197 | (street.properties.distance - survey.surveyed_distance) / 2; 198 | let endOffset = 199 | street.properties.distance - 200 | (street.properties.distance - survey.surveyed_distance) / 2; 201 | 202 | if (street.properties.distance <= survey.surveyed_distance) { 203 | startOffset = 0; 204 | endOffset = street.properties.distance; 205 | } 206 | 207 | let centered = turf.lineString([ 208 | turf.along(street, startOffset, UNITS).geometry.coordinates, 209 | turf.along(street, endOffset, UNITS).geometry.coordinates, 210 | ]); 211 | for (let feature of survey.features) { 212 | try { 213 | if (feature.geometry.type === "Span") { 214 | let line = turf.lineSliceAlong( 215 | centered, 216 | feature.geometry.distances[0], 217 | feature.geometry.distances[1], 218 | UNITS 219 | ); 220 | 221 | let span = { 222 | type: "Feature", 223 | geometry: { 224 | type: "LineString", 225 | coordinates: line.geometry.coordinates, 226 | }, 227 | properties: { 228 | created_at: survey.created_at, 229 | cwheelid: "", // todo: figure out where to find this 230 | shst_ref_id: survey.shst_ref_id, 231 | ref_side: survey.side_of_street, 232 | ref_len: street.properties.distance, 233 | srv_dist: survey.surveyed_distance, 234 | srv_id: survey.id, 235 | feat_id: feature.id, 236 | label: feature.label, 237 | dst_st: feature.geometry.distances[0], 238 | dst_end: feature.geometry.distances[1], 239 | images: JSON.stringify(feature.images), 240 | }, 241 | }; 242 | 243 | let start = turf.point( 244 | span.geometry.coordinates[0], 245 | span.properties 246 | ); 247 | let end = turf.point( 248 | span.geometry.coordinates[span.geometry.coordinates.length - 1], 249 | span.properties 250 | ); 251 | 252 | for (let image of feature.images) { 253 | let pt = turf.along(centered, image.geometry.distance, UNITS); 254 | 255 | pt.properties.url = image.url; 256 | 257 | images.push(pt); 258 | } 259 | 260 | spans.push(span); 261 | spanPoints.push(start); 262 | spanPoints.push(end); 263 | spanAndPositionPoints.push(start); 264 | spanAndPositionPoints.push(end); 265 | } else if (feature.geometry.type === "Position") { 266 | // todo: Positions are being incorrectly stored with span style distances array. Fix upstream. 267 | let point = turf.along( 268 | centered, 269 | feature.geometry.distances[0], 270 | UNITS 271 | ); 272 | 273 | point.properties = { 274 | created_at: survey.created_at, 275 | cwheelid: "", // todo: figure out where to find this 276 | shst_ref_id: survey.shst_ref_id, 277 | ref_side: survey.side_of_street, 278 | ref_len: street.properties.distance, 279 | srv_dist: survey.surveyed_distance, 280 | srv_id: survey.id, 281 | feat_id: feature.id, 282 | label: feature.label, 283 | dst_st: feature.geometry.distances[0], 284 | images: JSON.stringify(feature.images), 285 | }; 286 | 287 | for (let image of feature.images) { 288 | let pt = turf.point(point.geometry.coordinates); 289 | 290 | pt.properties.url = image.url; 291 | 292 | images.push(pt); 293 | } 294 | 295 | positions.push(point); 296 | spanAndPositionPoints.push(point); 297 | } else { 298 | throw new Error("Unknown geometry type."); 299 | } 300 | } 301 | catch(e) { 302 | console.log('unable to export feature: ' + e); 303 | } 304 | 305 | } 306 | } 307 | } 308 | 309 | let exportDir = path.join(__dirname, "../export"); 310 | let zipDir = path.join(__dirname, "../export.zip"); 311 | 312 | try { 313 | rimraf.sync(exportDir); 314 | rimraf.sync(zipDir); 315 | } catch (e) { 316 | console.error(e); 317 | } 318 | mkdirp.sync(exportDir); 319 | 320 | await fs.promises.writeFile( 321 | path.join(exportDir, "spans.geojson"), 322 | JSON.stringify(turf.featureCollection(spans)), 323 | { 324 | name: "spans.geojson", 325 | } 326 | ); 327 | await fs.promises.writeFile( 328 | path.join(exportDir, "positions.geojson"), 329 | JSON.stringify(turf.featureCollection(positions)), 330 | { 331 | name: "positions.geojson", 332 | } 333 | ); 334 | await fs.promises.writeFile( 335 | path.join(exportDir, "spanPoints.geojson"), 336 | JSON.stringify(turf.featureCollection(spanPoints)), 337 | { 338 | name: "spanPoints.geojson", 339 | } 340 | ); 341 | await fs.promises.writeFile( 342 | path.join(exportDir, "spanAndPositionPoints.geojson"), 343 | JSON.stringify(turf.featureCollection(spanAndPositionPoints)), 344 | { 345 | name: "spanAndPositionPoints.geojson", 346 | } 347 | ); 348 | await fs.promises.writeFile( 349 | path.join(exportDir, "images.geojson"), 350 | JSON.stringify(turf.featureCollection(images)), 351 | { 352 | name: "images.geojson", 353 | } 354 | ); 355 | 356 | copydir.sync( 357 | path.join(__dirname, "../static/images/survey"), 358 | path.join(exportDir, "./images") 359 | ); 360 | 361 | var archive = archiver("zip", { 362 | zlib: { level: 9 }, // compression level 363 | }); 364 | 365 | let output = fs.createWriteStream(zipDir); 366 | archive.directory(exportDir, false); 367 | 368 | output.on("close", function () { 369 | res.status(200).download(zipDir); 370 | }); 371 | 372 | archive.pipe(output); 373 | 374 | archive.finalize(); 375 | }); 376 | 377 | app.get("/surveys/:ref", async (req, res) => { 378 | let ref = req.params.ref; 379 | let surveys = app.state.graph.surveys.get(ref); 380 | if (!surveys) { 381 | surveys = []; 382 | } 383 | 384 | res.status(200).send(surveys); 385 | }); 386 | 387 | app.post("/surveys/:ref", async (req, res) => { 388 | let ref = req.params.ref; 389 | let surveys = app.state.graph.surveys.get(ref); 390 | if (!surveys) { 391 | surveys = []; 392 | } 393 | surveys.push(req.body); 394 | app.state.graph.surveys.set(ref, surveys); 395 | 396 | await app.state.graph.save(GRAPH); 397 | 398 | res.status(200).send("Uploaded survey."); 399 | }); 400 | 401 | app.get("/admin", async (req, res) => { 402 | let template = ( 403 | await fs.promises.readFile( 404 | path.join(__dirname, "../templates/admin.html") 405 | ) 406 | ).toString(); 407 | res.send(template); 408 | }); 409 | 410 | app.post("/admin/update", async (req, res) => { 411 | res.status(200).send(wifiSettings); 412 | }); 413 | 414 | app.get("/admin/wifi", async (req, res) => { 415 | var wifiSettings = { mode: "ap", network: "", password: "" }; 416 | 417 | try { 418 | wifiSettings = JSON.parse( 419 | fs.readFileSync(path.join(__dirname, "../config/wifi.json")) 420 | ); 421 | } catch (e) { 422 | console.error(e); 423 | } 424 | // return wifiSettings 425 | res.status(200).send(wifiSettings); 426 | }); 427 | 428 | app.post("/admin/wifi", async (req, res) => { 429 | const wifiSettings = JSON.stringify(req.body); 430 | 431 | // todo validate wifi 432 | 433 | //write wifiSettings to config file 434 | fs.writeFileSync( 435 | path.join(__dirname, "../config/wifi.json"), 436 | wifiSettings 437 | ); 438 | 439 | var wpaConfTemplate = fs.readFileSync( 440 | path.join(__dirname, "../config/wpa_supplicant.conf.template"), 441 | "utf8" 442 | ); 443 | 444 | var wpaConf = wpaConfTemplate 445 | .replace("[NAME OF WIFI NETWORK]", req.body.network) 446 | .replace("[WIFI NETWORK PASSWORD]", req.body.password); 447 | 448 | fs.writeFileSync( 449 | path.join(__dirname, "../config/wpa_supplicant.conf"), 450 | wpaConf 451 | ); 452 | 453 | if (req.body.mode === "ap") 454 | child_process.execSync("sh switch-to-ap.sh", { 455 | cwd: "/home/pi/curb-wheel/", 456 | }); 457 | else if (req.body.mode === "wifi") 458 | child_process.execSync("sh switch-to-wifi.sh", { 459 | cwd: "/home/pi/curb-wheel/", 460 | }); 461 | // return wifiSettings 462 | res.status(200).send(wifiSettings); 463 | }); 464 | 465 | app.get("/admin/version", async (req, res) => { 466 | const versionNumber = JSON.parse( 467 | fs.readFileSync(path.join(__dirname, "../package.json")) 468 | ).version; 469 | 470 | // return version number 471 | res.status(200).send({ version: versionNumber }); 472 | }); 473 | 474 | app.get("/digitizer", async (req, res) => { 475 | let template = ( 476 | await fs.promises.readFile( 477 | path.join(__dirname, "../templates/digitizer.html") 478 | ) 479 | ).toString(); 480 | res.send(template); 481 | }); 482 | 483 | app.get("/*", async (req, res) => { 484 | let template = ( 485 | await fs.promises.readFile( 486 | path.join(__dirname, "../templates/404.html") 487 | ) 488 | ).toString(); 489 | 490 | res.status(404).send(template); 491 | }); 492 | 493 | let server = app.listen(PORT, () => { 494 | console.log("listening on: 127.0.0.1:" + PORT); 495 | return resolve(server); 496 | }); 497 | }); 498 | } 499 | 500 | function uuid() { 501 | return ( 502 | Math.random().toString(36).substring(2, 15) + 503 | Math.random().toString(36).substring(2, 15) 504 | ); 505 | } 506 | 507 | if (require.main === module) { 508 | // cli 509 | main(); 510 | } else { 511 | // lib 512 | module.exports = main; 513 | } 514 | -------------------------------------------------------------------------------- /startup-simulator.sh: -------------------------------------------------------------------------------- 1 | sh setup-ramdisk-simulator.sh 2 | 3 | TILESERVER_PID=./tileserver.pid 4 | if test -f "$TILESERVER_PID"; then 5 | pkill -F $TILESERVER_PID 6 | fi 7 | 8 | TILE_PATH=./extract.mbtiles 9 | if test -f "$TILE_PATH"; then 10 | echo "run tileserver" 11 | tileserver-gl-light $TILE_PATH & 12 | echo $! > $TILESERVER_PID 13 | fi 14 | 15 | 16 | WHEEL_PID=./wheel.pid 17 | if test -f "$WHEEL_PID"; then 18 | pkill -F $WHEEL_PID 19 | fi 20 | 21 | 22 | echo "0" > ram/counter.txt 23 | python2.7 python/wheel-simulator.py & 24 | echo $! > $WHEEL_PID 25 | -------------------------------------------------------------------------------- /startup.rc.d: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | ### BEGIN INIT INFO 3 | # Provides: wheelstart 4 | # Required-Start: $remote_fs $syslog 5 | # Required-Stop: $remote_fs $syslog 6 | # Default-Start: 2 3 4 5 7 | # Default-Stop: 0 1 6 8 | # Short-Description: Start wheel daemon at boot time 9 | # Description: Enable wheel service provided by daemon. 10 | ### END INIT INFO 11 | 12 | 13 | cd /home/pi/curb-wheel/ 14 | npm start -------------------------------------------------------------------------------- /startup.sh: -------------------------------------------------------------------------------- 1 | 2 | cd /home/pi/curb-wheel/ 3 | 4 | sh setup-ramdisk.sh 5 | 6 | WHEEL_PID=./wheel.pid 7 | if test -f "$WHEEL_PID"; then 8 | pkill -F $WHEEL_PID 9 | fi 10 | 11 | echo "0" > ram/counter.txt 12 | python python/wheel.py & 13 | echo $! > $WHEEL_PID 14 | 15 | TILESERVER_PID=./tileserver.pid 16 | if test -f "$TILESERVER_PID"; then 17 | pkill -F $TILESERVER_PID 18 | fi 19 | 20 | TILE_PATH=./extract.mbtiles 21 | if test -f "$TILE_PATH"; then 22 | echo "run tileserver" 23 | tileserver-gl-light $TILE_PATH & 24 | echo $! > $TILESERVER_PID 25 | fi 26 | 27 | sudo modprobe ledtrig_heartbeat 28 | sudo su root -c 'echo heartbeat >/sys/class/leds/led0/trigger' 29 | -------------------------------------------------------------------------------- /static/autocomplete.css: -------------------------------------------------------------------------------- 1 | * { box-sizing: border-box; } 2 | 3 | .autocomplete { 4 | /*the container must be positioned relative:*/ 5 | position: relative; 6 | display: inline-block; 7 | } 8 | 9 | ::placeholder { 10 | font-style:italic; 11 | }; 12 | input { 13 | /*border: 1px solid transparent;*/ 14 | padding: 10px; 15 | font-size: 16px; 16 | } 17 | input[type=text] { 18 | width: 100%; 19 | } 20 | input[type=submit] { 21 | background-color: #abcdef; 22 | } 23 | 24 | input:focus + .autocomplete-items, 25 | .autocomplete-items:hover { 26 | opacity:1; 27 | pointer-events:all; 28 | border: 1px solid steelblue; 29 | border-top: none; 30 | } 31 | 32 | .autocomplete-items { 33 | position: absolute; 34 | z-index: 99; 35 | /*position the autocomplete items to be the same width as the container:*/ 36 | top: 100%; 37 | left: 0; 38 | right: 0; 39 | border-radius: 0px 0px 5px 5px; 40 | opacity:0; 41 | pointer-events:none; 42 | overflow: hidden; 43 | } 44 | .autocomplete-items div { 45 | padding: 8px 10px; 46 | cursor: pointer; 47 | background-color: #fafafa; 48 | border-bottom: 1px solid #eee; 49 | text-align:right; 50 | color:#999; 51 | } 52 | 53 | .autocomplete-items div.autocomplete-active { 54 | /*when navigating through the items using the arrow keys:*/ 55 | background-color: #fff !important; 56 | color:black; 57 | } 58 | 59 | /*apply inactive styling to "active" choices, when mouse is hovering something else*/ 60 | .autocomplete-items:hover .autocomplete-active { 61 | background: #fafafa !important; 62 | color:#999; 63 | } 64 | 65 | .autocomplete-items div:hover { 66 | /*when hovering an item:*/ 67 | background-color: #fff !important; 68 | color:black; 69 | } -------------------------------------------------------------------------------- /static/autocomplete.js: -------------------------------------------------------------------------------- 1 | function autocomplete(inp, options) { 2 | 3 | // input element, 4 | 5 | // options.values: array of values to match to, 6 | // options.match: optional "any" part of string, or "start" of string only 7 | // options.minLength: min length of input to 8 | // onEnter: optional function to call when enter key pressed. default is blurring from input 9 | // options.inputTransform: optional function to match to a transformation of the input value, 10 | // options.outputTransform: optional function to set input value upon selecting item 11 | 12 | var currentFocus; 13 | 14 | /*execute a function when someone writes in the text field:*/ 15 | 16 | inp.addEventListener("focus", onInput) 17 | inp.addEventListener("input", onInput); 18 | 19 | /*execute a function presses a key on the keyboard:*/ 20 | inp.addEventListener("keydown", function(e) { 21 | var x = document.getElementById(this.id + "autocomplete-list"); 22 | if (x) x = x.getElementsByTagName("div"); 23 | if (e.keyCode == 40) { 24 | /*If the arrow DOWN key is pressed, 25 | increase the currentFocus variable:*/ 26 | currentFocus++; 27 | /*and and make the current item more visible:*/ 28 | addActive(x); 29 | } else if (e.keyCode == 38) { //up 30 | /*If the arrow UP key is pressed, 31 | decrease the currentFocus variable:*/ 32 | currentFocus--; 33 | /*and and make the current item more visible:*/ 34 | addActive(x); 35 | } else if (e.keyCode == 13) { 36 | /*If the ENTER key is pressed, prevent the form from being submitted,*/ 37 | e.preventDefault(); 38 | if (currentFocus > -1) { 39 | /*and simulate a click on the "active" item:*/ 40 | if (x) x[currentFocus].click(); 41 | } 42 | } 43 | }); 44 | 45 | function onInput(){ 46 | 47 | var a, b, i, val = options.inputTransform ? options.inputTransform(this.value) : this.value; 48 | 49 | /*close any already open lists of autocompleted values*/ 50 | closeAllLists(); 51 | // if (!val) { return false;} 52 | currentFocus = -1; 53 | /*create a DIV element that will contain the items (values):*/ 54 | a = document.createElement("DIV"); 55 | a.setAttribute("id", this.id + "autocomplete-list"); 56 | a.setAttribute("class", "autocomplete-items"); 57 | /*append the DIV element as a child of the autocomplete container:*/ 58 | this.parentNode.appendChild(a); 59 | 60 | /*for each item in the array...*/ 61 | for (i = 0; i < options.values.length; i++) { 62 | 63 | var matchFound = options.match === 'any' ? options.values[i].includes(val) : options.values[i].substr(0, val.length).toUpperCase() == val.toUpperCase() 64 | /*check if the item starts with the same letters as the text field value:*/ 65 | if (matchFound) { 66 | /*create a DIV element for each matching element:*/ 67 | b = document.createElement("DIV"); 68 | /*make the matching letters bold:*/ 69 | b.innerHTML = options.values[i].replace(val, `${val}`) 70 | // "" + options.values[i].substr(0, val.length) + ""; 71 | // b.innerHTML += options.values[i].substr(val.length); 72 | /*insert a input field that will hold the current array item's value:*/ 73 | b.innerHTML += ""; 74 | 75 | /*execute a function when someone clicks on the item value (DIV element):*/ 76 | b.addEventListener("click", function(e) { 77 | 78 | // apply the selected value, optionally with an output transform 79 | var selectedValue = this.getElementsByTagName("input")[0].value; 80 | var valueToApply = options.outputTransform ? options.outputTransform(inp.value, selectedValue) : selectedValue; 81 | inp.value = valueToApply; 82 | 83 | /*close the list of autocompleted values, 84 | (or any other open lists of autocompleted values:*/ 85 | closeAllLists(); 86 | options.onEnter ? options.onEnter(inp) : inp.blur() 87 | }); 88 | a.appendChild(b); 89 | } 90 | } 91 | } 92 | 93 | function addActive(x) { 94 | /*a function to classify an item as "active":*/ 95 | if (!x) return false; 96 | /*start by removing the "active" class on all items:*/ 97 | removeActive(x); 98 | if (currentFocus >= x.length) currentFocus = 0; 99 | if (currentFocus < 0) currentFocus = (x.length - 1); 100 | /*add class "autocomplete-active":*/ 101 | x[currentFocus].classList.add("autocomplete-active"); 102 | } 103 | function removeActive(x) { 104 | /*a function to remove the "active" class from all autocomplete items:*/ 105 | for (var i = 0; i < x.length; i++) { 106 | x[i].classList.remove("autocomplete-active"); 107 | } 108 | } 109 | function closeAllLists(elmnt) { 110 | /*close all autocomplete lists in the document, 111 | except the one passed as an argument:*/ 112 | var x = document.getElementsByClassName("autocomplete-items"); 113 | for (var i = 0; i < x.length; i++) { 114 | if (elmnt != x[i] && elmnt != inp) { 115 | x[i].parentNode.removeChild(x[i]); 116 | } 117 | } 118 | } 119 | /*execute a function when someone clicks in the document:*/ 120 | document.addEventListener("click", function (e) { 121 | // closeAllLists(e.target); 122 | }); 123 | 124 | } -------------------------------------------------------------------------------- /static/basics.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | margin:0; 7 | font-size: 16px; 8 | -moz-osx-font-smoothing: grayscale; 9 | -webkit-font-smoothing: antialiased; 10 | min-width: 300px; 11 | overflow-x: hidden; 12 | overflow-y: hidden; 13 | text-rendering: optimizeLegibility; 14 | -webkit-text-size-adjust: 100%; 15 | -moz-text-size-adjust: 100%; 16 | -ms-text-size-adjust: 100%; 17 | text-size-adjust: 100%; 18 | box-sizing: inherit; 19 | font-weight: 400; 20 | line-height: 1.5; 21 | color:#4a4a4a; 22 | font-family:BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif; 23 | } 24 | 25 | 26 | /*remove arrows on number inputs*/ 27 | input::-webkit-outer-spin-button, 28 | input::-webkit-inner-spin-button { 29 | -webkit-appearance: none; 30 | margin: 0; 31 | } 32 | 33 | /* Firefox */ 34 | input[type=number] { 35 | -moz-appearance: textfield; 36 | } 37 | 38 | ::-webkit-scrollbar{ 39 | width:0; 40 | height:0; 41 | /*-webkit-appearance: none;*/ 42 | } 43 | 44 | .scroll { 45 | overflow:scroll; 46 | } 47 | 48 | .hidden { 49 | display:none; 50 | } 51 | 52 | .button { 53 | background:steelblue; 54 | color:white; 55 | cursor:pointer; 56 | border-radius:2px; 57 | } 58 | /*utility classes*/ 59 | .big { 60 | font-size: 1.25em; 61 | } 62 | .small { 63 | font-size:0.8em; 64 | } 65 | 66 | .blue { 67 | color:steelblue; 68 | } 69 | 70 | .quiet { 71 | opacity:0.5; 72 | } 73 | 74 | .strong { 75 | font-weight:bold; 76 | } 77 | 78 | .fl { 79 | float:left; 80 | } 81 | 82 | .fr { 83 | float:right; 84 | } 85 | 86 | .text-center { 87 | text-align: center 88 | } 89 | 90 | .pin-bottom { 91 | position: absolute; 92 | bottom:0; 93 | } 94 | 95 | /*paddings*/ 96 | .p10 { 97 | padding:10px; 98 | } 99 | /*margins*/ 100 | 101 | .m10 { 102 | margin:10px; 103 | } 104 | 105 | .m20 { 106 | margin:20px; 107 | } 108 | .mt5 { 109 | margin-top:5px; 110 | } 111 | 112 | .mt10 { 113 | margin-top:10px; 114 | } 115 | 116 | .mt20 { 117 | margin-top:20px; 118 | } 119 | 120 | .mt30 { 121 | margin-top:30px; 122 | } 123 | 124 | .mt40 { 125 | margin-top:40px; 126 | } 127 | 128 | .mt50 { 129 | margin-top:50px; 130 | } 131 | 132 | .mt100 { 133 | margin-top:100px; 134 | } 135 | 136 | .mt200 { 137 | margin-top:200px; 138 | } 139 | 140 | .mb10 { 141 | margin-bottom: 10px 142 | } 143 | 144 | .mb30 { 145 | margin-bottom: 30px 146 | } 147 | 148 | .mb200 { 149 | margin-bottom:200px; 150 | } 151 | 152 | .mr10 { 153 | margin-right: 10px 154 | } 155 | 156 | .mr20 { 157 | margin-right: 20px 158 | } 159 | 160 | .inlineBlock { 161 | display: inline-block; 162 | } 163 | 164 | /* columns */ 165 | 166 | .col3{ 167 | width:25%; 168 | flex:none; 169 | } 170 | 171 | .col4{ 172 | width:33.333333333%; 173 | flex:none; 174 | } 175 | 176 | .col6{ 177 | width:50%; 178 | flex:none; 179 | float:left; 180 | } 181 | .col9{ 182 | width:75%; 183 | flex:none; 184 | float:left; 185 | } 186 | 187 | /*anchoring*/ 188 | 189 | .pin-tl { 190 | left:0; 191 | top: 0; 192 | position:absolute; 193 | } 194 | 195 | .pin-bl { 196 | left:0; 197 | bottom: 0; 198 | position:absolute; 199 | } 200 | 201 | .z100 { 202 | z-index: 100 203 | } 204 | /*heights*/ 205 | 206 | .fullHeight { 207 | height:100%; 208 | } -------------------------------------------------------------------------------- /static/digitizer.js: -------------------------------------------------------------------------------- 1 | var app = { 2 | 3 | state: { 4 | activeFeatureIndex: 0, 5 | }, 6 | 7 | ui: { 8 | 9 | entry: { 10 | 11 | appendProperty: function(d,i) { 12 | 13 | var container = d3.select(this) 14 | 15 | // append label 16 | container 17 | .attr('prop', d.param) 18 | .append('div') 19 | .attr('class', 'mr10 inlineBlock quiet p10') 20 | .text(d.param); 21 | 22 | var validate = app.constants.validate[d.param]; 23 | 24 | if (validate.inputType === 'text') appendInput() 25 | else if(validate.inputType === 'dropdown') appendDropdown(); 26 | 27 | else { 28 | 29 | if (validate.allowCustomValues === false) appendDropdown() 30 | else appendInput() 31 | } 32 | 33 | 34 | function appendInput(){ 35 | 36 | var validate = app.constants.validate[d.param]; 37 | 38 | var textInput = 39 | container 40 | .append('div') 41 | .attr('class', 'fr') 42 | .style('width', '50%') 43 | .append('div') 44 | .attr('class', 'autocomplete') 45 | .style('width', '100%') 46 | .append('input') 47 | .attr('prop', d.param) 48 | .attr('type', validate.type) 49 | .attr('class', 'fr') 50 | .attr('onclick', 'this.setSelectionRange(this.value.length, this.value.length)') 51 | .attr('placeholder', d.placeholder) 52 | 53 | 54 | if (validate.oneOf) { 55 | 56 | autocomplete( 57 | 58 | textInput.node(), 59 | { 60 | values: validate.oneOf, 61 | match: 'any', 62 | onEnter: app.utils.autocomplete.keepTyping, 63 | inputTransform: app.utils.autocomplete.lastListItem, 64 | outputTransform: app.utils.autocomplete.returnFullListString 65 | } 66 | ) 67 | } 68 | 69 | return textInput 70 | } 71 | 72 | function appendDropdown(){ 73 | 74 | var dropdown = container 75 | .append('select') 76 | .attr('prop', d.param) 77 | .attr('class', 'fr m10') 78 | 79 | dropdown 80 | .selectAll('option') 81 | .data(validate.oneOf) 82 | .enter() 83 | .append('option') 84 | .attr('value', function(d){return d}) 85 | .text(d=>d) 86 | 87 | return dropdown 88 | } 89 | 90 | }, 91 | 92 | 93 | onChange: function(d,i){ 94 | 95 | var entry = d3.select(this) 96 | 97 | entry.selectAll('input, select') 98 | .on('change', c) 99 | .on('keyup', c) 100 | function c(data){ 101 | 102 | var prop = d3.select(this).attr('prop') 103 | var value = d3.select(this).property('value') 104 | 105 | var target = d; 106 | var subdirectory = app.constants.validate[prop].output 107 | for (item of subdirectory) target = target[item === '_index' ? i : item] 108 | 109 | // apply transform function if there is one 110 | var tfFn = app.constants.validate[prop].transform; 111 | target[prop] = tfFn ? tfFn(value) : value; 112 | 113 | 114 | // propagations 115 | var propagationEntry = app.constants.ui.entryPropagations[prop]; 116 | if (propagationEntry) { 117 | 118 | var propagatingRule = propagationEntry.propagatingValues[value] || false; 119 | var destination = entry.select(`div[prop=${propagationEntry.destinationProp}]`) 120 | 121 | destination 122 | .classed('hidden', !propagatingRule) 123 | 124 | if (propagatingRule) { 125 | 126 | var input = destination.select('input') 127 | .attr('placeholder', propagatingRule.placeholder) 128 | .property('value', '') 129 | .node() 130 | 131 | 132 | autocomplete(input, { 133 | values: propagatingRule.values || [] 134 | }) 135 | 136 | input.focus(); 137 | } 138 | } 139 | 140 | 141 | } 142 | }, 143 | 144 | updateRegulation: (entries) =>{ 145 | 146 | var rules = entries 147 | .select('.rules') 148 | .selectAll('.rule') 149 | .data(d=>d.output.regulations) 150 | .enter() 151 | .append('div') 152 | .attr('class', 'rule m10') 153 | 154 | var props = rules 155 | .selectAll('.property') 156 | .data(d=>app.constants.ui.regulationParams.map(obj=>{ 157 | return {param: obj.param, value: d.rule[obj.param], placeholder: obj.placeholder} 158 | })) 159 | .enter() 160 | .append('div') 161 | .attr('class', 'property') 162 | .each(app.ui.entry.appendProperty) 163 | .select('input, select') 164 | 165 | return rules 166 | } 167 | } 168 | }, 169 | constants: { 170 | 171 | ui: { 172 | 173 | entryPropagations: { 174 | assetType: { 175 | destinationProp: 'assetSubtype', 176 | propagatingValues: { 177 | 178 | 'pavement marking': { 179 | placeholder: 'Marking type', 180 | values: [ 181 | 'ramp', 182 | 'driveway', 183 | 'street' 184 | ] 185 | }, 186 | 187 | 'curb cut': { 188 | placeholder: 'Cut type', 189 | values: [ 190 | 'bike', 191 | 'bus', 192 | 'taxi', 193 | 'arrow', 194 | 'diagonal lines', 195 | 'zigzag', 196 | 'parallel parking', 197 | 'perpendicular parking', 198 | 'yellow', 199 | 'red', 200 | 'blue', 201 | 'ISA' 202 | ] 203 | }, 204 | 205 | 'curb paint': { 206 | placeholder: 'Paint color' 207 | }, 208 | } 209 | } 210 | }, 211 | 212 | entryParams: [ 213 | { 214 | param: 'shstRefId', 215 | placeholder: 'unique identifier', 216 | inputProp: 'shst_ref_id' 217 | }, 218 | 219 | { 220 | param: 'sideOfStreet', 221 | placeholder: 'street side', 222 | inputProp: 'ref_side' 223 | }, 224 | 225 | { 226 | param: 'shstLocationStart', 227 | placeholder: 'start of regulation', 228 | inputProp: 'dst_st' 229 | }, 230 | { 231 | param: 'shstLocationEnd', 232 | placeholder: 'end of regulation', 233 | inputProp: 'dst_end' 234 | }, 235 | { 236 | param: 'assetType' 237 | }, 238 | { 239 | param: 'assetSubtype', 240 | defaultHidden: true 241 | } 242 | 243 | ], 244 | 245 | regulationParams: [ 246 | { 247 | param: 'activity' 248 | }, 249 | { 250 | param: 'maxStay' 251 | }, 252 | { 253 | param: 'userClasses', 254 | placeholder: 'Comma-delimited values' 255 | }, 256 | { 257 | param: 'userSubClasses', 258 | placeholder: 'Comma-delimited values' 259 | }, 260 | { 261 | param: 'payment' 262 | }, 263 | 264 | 265 | { 266 | param: 'daysOfWeek', 267 | // inputType: 'text', 268 | placeholder: 'Comma-delimited values' 269 | }, 270 | { 271 | param: 'timesOfDay', 272 | placeholder: 'Comma-delimited, each in HH:MM-HH:MM' 273 | } 274 | ] 275 | }, 276 | 277 | 278 | validate: { 279 | 280 | shstRefId: { 281 | type: 'string', 282 | output: ['output', 'location'] 283 | }, 284 | 285 | sideOfStreet: { 286 | type: 'string', 287 | output: ['output', 'location'], 288 | oneOf: ['left', 'right', 'unknown'], 289 | allowCustomValues: false 290 | }, 291 | 292 | shstLocationStart: { 293 | type: 'number', 294 | output: ['output', 'location'], 295 | transform: (input) => parseFloat(input) 296 | }, 297 | 298 | shstLocationEnd: { 299 | type: 'number', 300 | output: ['output', 'location'], 301 | transform: (input) => parseFloat(input) 302 | }, 303 | 304 | assetType: { 305 | type: 'string', 306 | oneOf: [ 307 | 'sign', 308 | 'curb paint', 309 | 'hydrant', 310 | 'bus stop', 311 | 'crosswalk', 312 | 'bike rack', 313 | 'curb extension', 314 | 'bollards', 315 | 'fence', 316 | 'parking meter', 317 | 'pavement marking', 318 | 'curb cut' 319 | ], 320 | output: ['output', 'location'], 321 | allowCustomValues: false, 322 | subParameter: 'assetSubtype', 323 | 324 | }, 325 | 326 | assetSubtype: { 327 | allowCustomValues: true, 328 | output: ['output', 'location'], 329 | }, 330 | 331 | activity: { 332 | oneOf: [ 333 | 'standing', 334 | 'no standing', 335 | 'loading', 336 | 'no loading', 337 | 'parking', 338 | 'no parking' 339 | ], 340 | allowCustomValues: false, 341 | output: ['rule'] 342 | }, 343 | 344 | maxStay: { 345 | type: 'number', 346 | oneOf: [5, 10, 15, 20, 30, 45, 60, 120, 180, 240, 300, 360, 480], 347 | allowCustomValues: false, 348 | output: ['rule'] 349 | }, 350 | 351 | payment: { 352 | type: 'number', 353 | oneOf: [false, true], 354 | allowCustomValues: false, 355 | output: ['rule'] 356 | }, 357 | 358 | userClasses: { 359 | oneOf: [ 360 | 'bicycle', 361 | 'bikeshare', 362 | 'bus', 363 | 'car share', 364 | 'carpool', 365 | 'commercial', 366 | 'compact', 367 | 'construction', 368 | 'diplomat', 369 | 'electric', 370 | 'emergency', 371 | 'food truck', 372 | 'handicap', 373 | 'micromobility', 374 | 'motorcycle', 375 | 'official', 376 | 'passenger', 377 | 'permit', 378 | 'police', 379 | 'rideshare', 380 | 'staff', 381 | 'student', 382 | 'taxi', 383 | 'truck', 384 | 'visitor' 385 | ], 386 | output: ['rule'], 387 | allowCustomValues: true, 388 | transform: (input) =>{ 389 | return input.split(', ') 390 | } 391 | }, 392 | 393 | userSubClasses: { 394 | type: 'array', 395 | arrayMemberType: 'string', 396 | allowCustomValues: true, 397 | output: ['rule'], 398 | transform: (input) =>{ 399 | return input.split(', ') 400 | } 401 | }, 402 | 403 | daysOfWeek: { 404 | type: 'array', 405 | arrayMemberType:'string', 406 | allowCustomValues: false, 407 | inputType: 'text', 408 | oneOf: ['mo', 'tu', 'we', 'th', 'fr', 'sa', 'su'], 409 | transform: (input) =>{ 410 | return input.split(', ') 411 | }, 412 | output: ['rule'] 413 | }, 414 | 415 | timesOfDay: { 416 | type: 'array', 417 | arrayMemberType:'string', 418 | allowCustomValues: true, 419 | transform: (input)=>{ 420 | var arr = input.split(', ') 421 | .map(period=>{ 422 | var startEnd = period.split('-'); 423 | return { 424 | start: startEnd[0], 425 | end: startEnd[1] 426 | } 427 | }) 428 | 429 | return arr 430 | }, 431 | output: ['rule'] 432 | } 433 | }, 434 | 435 | regulation: { 436 | 437 | } 438 | }, 439 | 440 | io: { 441 | 442 | export: () => { 443 | 444 | app.state.data.features = app.state.data.features.map(ft => { 445 | 446 | ft = { 447 | geometry: ft.geometry, 448 | properties: ft.output 449 | } 450 | 451 | return ft 452 | }) 453 | 454 | var element = document.createElement('a'); 455 | 456 | const blob = new Blob([JSON.stringify(app.state.data)], {type: "application/json"}); 457 | var url = window.URL.createObjectURL(blob); 458 | 459 | element.setAttribute('href', url); 460 | element.setAttribute('download', 'curblr_'+Date.now()+'.json'); 461 | 462 | element.style.display = 'none'; 463 | document.body.appendChild(element); 464 | 465 | element.click(); 466 | document.body.removeChild(element); 467 | } 468 | 469 | }, 470 | 471 | 472 | init: { 473 | 474 | map: () => { 475 | 476 | mapboxgl.accessToken = "pk.eyJ1IjoibW9yZ2FuaGVybG9ja2VyIiwiYSI6Ii1zLU4xOWMifQ.FubD68OEerk74AYCLduMZQ"; 477 | 478 | var map = new mapboxgl.Map({ 479 | container: 'map', 480 | style: 'mapbox://styles/mapbox/light-v9' 481 | }) 482 | .on('load', () => { 483 | 484 | map.fitBounds(turf.bbox(app.state.data), {duration:200, padding:100}); 485 | map 486 | // .addLayer({ 487 | // id: 'spans', 488 | // type: 'fill-extrusion', 489 | // source: { 490 | // type:'geojson', 491 | // data: data 492 | // }, 493 | // paint: { 494 | // 'fill-extrusion-color':'red', 495 | // 'fill-extrusion-base': 2, 496 | // 'fill-extrusion-height':10, 497 | // // 'line-width':5, 498 | // 'fill-extrusion-opacity':0.2 499 | // } 500 | // }) 501 | .addLayer({ 502 | id: 'spans', 503 | type: 'line', 504 | source: { 505 | type:'geojson', 506 | data: app.state.data 507 | }, 508 | layout: { 509 | 'line-cap':'round' 510 | }, 511 | paint: { 512 | 'line-color': [ 513 | 'match', 514 | ['get', 'id'], 515 | 0, 'steelblue', 516 | '#ccc' 517 | ], 518 | 'line-width':{ 519 | base:2, 520 | stops: [[6, 1], [22, 80]] 521 | }, 522 | 'line-opacity':0.75, 523 | 'line-offset': { 524 | base:2, 525 | stops: [[12, 3], [22, 100]] 526 | } 527 | } 528 | }) 529 | }) 530 | 531 | app.ui.map = map; 532 | }, 533 | 534 | ui: () =>{ 535 | 536 | // prep data 537 | app.state.data.features.forEach((d,i)=>{ 538 | 539 | d.properties.id = i; 540 | d.properties.images = JSON.parse(d.properties.images); 541 | 542 | //create separate object for curblr properties 543 | d.output = { 544 | regulations:[], 545 | location:{} 546 | } 547 | 548 | // extract survey values into curblr 549 | app.constants.ui.entryParams 550 | .forEach(param=>{ 551 | d.output.location[param.param] = d.properties[param.inputProp] 552 | }) 553 | 554 | }) 555 | 556 | d3.select('#map') 557 | .append('div') 558 | .attr('class', 'button pin-tl z100 inlineBlock p10 strong m10') 559 | .text('Export CurbLR') 560 | .attr('onclick', 'app.io.export()') 561 | 562 | 563 | var entries = 564 | 565 | d3.select('#dataPanel') 566 | .selectAll('.entry') 567 | .data(app.state.data.features, d=>d.id) 568 | .enter() 569 | .append('div') 570 | .attr('class', 'entry m20') 571 | .on('mouseenter', (d,i) => { 572 | 573 | app.state.activeFeatureIndex = i; 574 | 575 | var makeRule = (active, inactive) =>{ 576 | return [ 577 | 'match', 578 | ['get', 'id'], 579 | app.state.activeFeatureIndex, active, 580 | inactive 581 | ] 582 | } 583 | 584 | app.ui.map.setPaintProperty('spans', 'line-color', makeRule('steelblue', '#ccc')) 585 | // .setPaintProperty('spans', 'line-width', makeRule( 586 | // 80, 587 | // ['interpolate', ['exponential', 2], ['zoom'], 6,1,22,80], 588 | // )) 589 | }) 590 | 591 | 592 | 593 | entries 594 | .append('div') 595 | .text(d => d.properties.label) 596 | .attr('class', 'm10 blue strong') 597 | 598 | 599 | var params = 600 | entries 601 | .selectAll('.property') 602 | .data(d=>app.constants.ui.entryParams.map(obj=>{ 603 | var param = obj.param 604 | return { 605 | param: param, 606 | value: d.output.location[param], 607 | placeholder:obj.placeholder, 608 | hidden: obj.defaultHidden 609 | } 610 | })) 611 | .enter() 612 | .append('div') 613 | .attr('class', 'property') 614 | .classed('hidden', d=>d.hidden) 615 | 616 | params 617 | .each(app.ui.entry.appendProperty) 618 | 619 | entries 620 | .each(prepopulateProperties) 621 | .each(app.ui.entry.onChange) 622 | 623 | function prepopulateProperties(d,i){ 624 | d3.select(this) 625 | .selectAll('.property') 626 | .select('input, select') 627 | .property('value', d=>d.value) 628 | }; 629 | 630 | 631 | var regulations = 632 | entries 633 | .append('div') 634 | .attr('class', 'property') 635 | 636 | regulations 637 | .append('div') 638 | .attr('class', 'mr10 inlineBlock quiet p10') 639 | .text('regulations'); 640 | 641 | regulations 642 | .append('div') 643 | .text('Add') 644 | .attr('class', 'fr clickable p10') 645 | .on('click', (d,i) => { 646 | 647 | d.output.regulations.push({ 648 | rule: { 649 | activity: 'standing', 650 | // priorityCategory: null, 651 | maxStay: 5, 652 | payment: false, 653 | userClasses: [], 654 | userSubClasses: [], 655 | daysOfWeek: [], 656 | timesOfDay: [] 657 | }, 658 | 659 | timeSpans: [ 660 | ] 661 | }) 662 | 663 | app.ui.entry 664 | .updateRegulation(entries) 665 | .each(app.ui.entry.onChange) 666 | }) 667 | 668 | regulations 669 | .append('div') 670 | .attr('class', 'rules') 671 | 672 | var images = 673 | entries 674 | .append('div') 675 | .attr('class', 'images p10 property') 676 | 677 | images 678 | .append('div') 679 | .attr('class', 'mr10 quiet mb10') 680 | .text('images'); 681 | images 682 | .selectAll('img') 683 | .data(d=>d.properties.images) 684 | .enter() 685 | .append('div') 686 | .style('background-image', d=>`url(${d.url})`) 687 | .attr('class', 'inlineBlock image mr10') 688 | 689 | } 690 | }, 691 | 692 | utils: { 693 | 694 | autocomplete: { 695 | 696 | // return the last item in a comma-delimited list, for autocomplete input 697 | lastListItem: (listString)=>{ 698 | 699 | var lastItemStart = listString.lastIndexOf(', ') 700 | if (lastItemStart ===-1) return listString 701 | 702 | var arrayed = listString.split(', ') 703 | var lastItem = arrayed[arrayed.length-1] 704 | 705 | return lastItem 706 | }, 707 | 708 | // returns a transformed value from an autocomplete selection 709 | 710 | returnFullListString: (listString, matchedItem) =>{ 711 | 712 | var lastItemStart = listString.lastIndexOf(', ') 713 | if (lastItemStart ===-1) return matchedItem 714 | 715 | var arrayed = listString.split(', ') 716 | arrayed[arrayed.length-1] = matchedItem 717 | 718 | return arrayed.join(', ') 719 | }, 720 | 721 | // empty function to keep typing stringed arrays when pressing enter button 722 | 723 | keepTyping: (input) =>{ 724 | // input.value += ', ' 725 | } 726 | 727 | } 728 | } 729 | } 730 | 731 | 732 | -------------------------------------------------------------------------------- /static/frontend.md: -------------------------------------------------------------------------------- 1 | # frontend.md 2 | 3 | An overview of the frontend architecture. 4 | 5 | ## Modes 6 | 7 | Broadly, the app breaks down the surveying task into a series of UI `modes`, each with its own interface: 8 | 9 | - `selectStreet`: presents user with a map to choose a street to survey 10 | - `selectDirection`: user chooses side of street to survey. reuses the map from `selectStreet` 11 | - `rolling`: the main interface for measuring a curb, with options to add/remove curb regulations to take photos for them 12 | - `addZone`: a menu for adding a new curb regulation ("zone" in the UI) 13 | 14 | Visually, these slide left to right in the UI. The back arrow on the upper left generally takes the user back a mode. 15 | 16 | ## The map 17 | 18 | `/static/map.js` holds much of the logic for instantiating the map, and wiring interactivity for the first two modes. The map is populated by two tilesets stored locally on the Pi: one for the basemap, and a second containing street geometry with associated metadata. The app pulls a street's attributes (name, length, forward and back refs) from this second tileset. 19 | 20 | ## Phone-wheel IO 21 | 22 | While in the rolling mode, the phone interfaces with the wheel via an access point emitted by the Pi. There are three main kinds of messaging, as covered in `app.io` 23 | 24 | - Roll progress: `app.io.wheelTick()` polls the Pi for the current distance the wheel has rolled. On app load, `app.init()` sets up a loop to fire this at regular intervals, to get the latest roll progress. 25 | - Upload image: when the surveyor takes a picture, `app.io.uploadImage()` POSTs it to the server 26 | - Upload survey: when a survey is complete, `app.io.uploadSurvey()` POSTs it to the server 27 | 28 | ## Rolling/surveying 29 | 30 | In the `rolling` mode, the app keeps track of the current progress down the curb, and accepts new curb zones from the user. An unlimited number of curb zones can run simultaneously as the wheel progresses down the street, each with three available actions: 31 | 32 | - Delete: remove the zone 33 | - Take photo: opens the device camera app to take a picture of something relevant to the curb zone. 34 | - Complete: at the end of each zone, record the current wheel progress as its end. This zone won't grow any longer as the wheel keeps rolling. 35 | 36 | All zones of the current survey are stored in `app.state.zones`, which will also be the payload when it comes time to upload the survey. 37 | 38 | ## DOM manipulation 39 | 40 | To maximize customizability and mobile performance, the app primarily uses D3.js in a very hands-on fashion to build and manipulate interface elements. At its most complex, the rolling mode invokes `app.ui.updateZones()` , which ingests `app.state.zones` to determine which zones have been added, modified, and deleted, and [performs those actions]([https://alignedleft.com/tutorials/d3/binding-data](https://alignedleft.com/tutorials/d3/binding-data)) on their corresponding DOM elements. New elements are built via `app.ui.buildZoneEntry()`. -------------------------------------------------------------------------------- /static/images/back.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/cog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/images/icons.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 13 | 16 | 19 | 22 | 25 | 28 | 31 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /static/images/plus.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /static/map.js: -------------------------------------------------------------------------------- 1 | mapboxgl.accessToken = 2 | "pk.eyJ1IjoibW9yZ2FuaGVybG9ja2VyIiwiYSI6Ii1zLU4xOWMifQ.FubD68OEerk74AYCLduMZQ"; 3 | 4 | app.ui.map = new mapboxgl.Map({ 5 | container: "map", 6 | style: location.hostname === 'localhost'? app.constants.mapStyle.fullStyle : `http://10.3.141.1:8080/styles/basic-preview/style.json`, 7 | hash: true, 8 | }); 9 | 10 | app.ui.map 11 | .fitBounds([bounds.slice(0,2), bounds.slice(2,4)]) 12 | .on("load", () => { 13 | 14 | app.ui.map 15 | .addLayer({ 16 | id: "streets", 17 | type: "line", 18 | source: { 19 | type: "geojson", 20 | data: app.constants.emptyGeojson, 21 | }, 22 | paint: { 23 | "line-width": 10, 24 | "line-opacity": 0.2, 25 | "line-color": "steelblue", 26 | }, 27 | }) 28 | .addLayer({ 29 | id: "surveyedStreets", 30 | type: "line", 31 | filter:['in', 'forward', 'none'], 32 | source: 'streets', 33 | paint: { 34 | "line-width": 16, 35 | "line-color": "steelblue", 36 | }, 37 | layout: { 38 | 'line-cap': 'round' 39 | } 40 | }) 41 | .addLayer({ 42 | id: "arrows", 43 | type: "symbol", 44 | source: { 45 | type: "geojson", 46 | data: app.constants.emptyGeojson, 47 | }, 48 | layout: { 49 | "text-keep-upright": false, 50 | "text-font": ["Noto Sans Regular"], 51 | "text-field": app.constants.mapStyle.arrows.direction.forward, 52 | "symbol-placement": "line", 53 | "symbol-spacing": { 54 | stops: [ 55 | [16, 20], 56 | [22, 80], 57 | ], 58 | }, 59 | "text-size": { 60 | stops: [ 61 | [16, 12], 62 | [22, 45], 63 | ], 64 | }, 65 | "text-allow-overlap": true, 66 | "text-ignore-placement": true, 67 | "text-offset": { 68 | base: 2, 69 | stops: app.constants.mapStyle.arrows.side.right, 70 | }, 71 | }, 72 | paint: { 73 | "text-color": "steelblue", 74 | }, 75 | }) 76 | .addLayer({ 77 | id: "youarehere", 78 | type: "circle", 79 | source: { 80 | type: "geojson", 81 | data: app.constants.emptyGeojson, 82 | }, 83 | paint: { 84 | "circle-color": "steelblue", 85 | }, 86 | }) 87 | .addLayer({ 88 | id: 'pois', 89 | minzoom:16, 90 | source: 'openmaptiles', 91 | 'source-layer': 'poi', 92 | type: 'symbol', 93 | layout: { 94 | 'text-field': '{name:latin}', 95 | "text-font": ["Noto Sans Regular"], 96 | 'text-size': { 97 | stops: [[16,12], [22,30]] 98 | }, 99 | 'text-max-width':6 100 | }, 101 | paint: { 102 | 'text-color': 'steelblue' 103 | } 104 | }) 105 | .on("click", "streets", (e) => { 106 | console.log(e.features) 107 | if (app.state.mode === "selectStreet") { 108 | var edge = e.features[0].geometry.coordinates; 109 | app.state.street = e.features[0].properties; 110 | 111 | app.ui.map.fitBounds(turf.bbox(e.features[0]), { 112 | padding: { 113 | top: 30, 114 | left: 30, 115 | right: 30, 116 | bottom: 30 + document.querySelector("#mapModal").offsetHeight, 117 | }, 118 | }); 119 | 120 | app.ui.map.getSource("arrows").setData({ 121 | type: "FeatureCollection", 122 | features: [e.features[0]], 123 | }); 124 | 125 | app.ui.mode.set("selectDirection"); 126 | } 127 | }) 128 | .on("moveend", (e) => { 129 | let zoom = app.ui.map.getZoom(); 130 | 131 | if (zoom >= 14) { 132 | let viewport = app.ui.map.getBounds(); 133 | 134 | let url = "/query?"; 135 | url += "minX=" + viewport._sw.lng; 136 | url += "&minY=" + viewport._sw.lat; 137 | url += "&maxX=" + viewport._ne.lng; 138 | url += "&maxY=" + viewport._ne.lat; 139 | 140 | fetch(url) 141 | .then((response) => { 142 | return response.json(); 143 | }) 144 | .then((streets) => { 145 | app.ui.map.getSource("streets").setData(streets); 146 | }); 147 | } else 148 | app.ui.map.getSource("streets").setData(app.constants.emptyGeojson); 149 | }); 150 | }); 151 | 152 | app.ui.map.switch = { 153 | side: () => { 154 | app.state.streetSide = app.state.streetSide === "right" ? "left" : "right"; 155 | 156 | app.ui.map.setLayoutProperty("arrows", "text-offset", { 157 | base: 2, 158 | stops: app.constants.mapStyle.arrows.side[app.state.streetSide], 159 | }); 160 | }, 161 | 162 | direction: () => { 163 | app.state.rollDirection = 164 | app.state.rollDirection === "forward" ? "back" : "forward"; 165 | 166 | app.ui.map.setLayoutProperty( 167 | "arrows", 168 | "text-field", 169 | app.constants.mapStyle.arrows.direction[app.state.rollDirection] 170 | ); 171 | }, 172 | }; 173 | 174 | -------------------------------------------------------------------------------- /static/mapbox-gl.css: -------------------------------------------------------------------------------- 1 | .mapboxgl-map{font:12px/20px Helvetica Neue,Arial,Helvetica,sans-serif;overflow:hidden;position:relative;-webkit-tap-highlight-color:rgba(0,0,0,0);text-align:left}.mapboxgl-map:-webkit-full-screen{width:100%;height:100%}.mapboxgl-canary{background-color:salmon}.mapboxgl-canvas-container.mapboxgl-interactive,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none}.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer{cursor:pointer}.mapboxgl-canvas-container.mapboxgl-interactive:active,.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active{cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas{touch-action:pan-x pan-y}.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:pinch-zoom}.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan .mapboxgl-canvas{touch-action:none}.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.mapboxgl-ctrl-top-left,.mapboxgl-ctrl-top-right{position:absolute;pointer-events:none;z-index:2}.mapboxgl-ctrl-top-left{top:0;left:0}.mapboxgl-ctrl-top-right{top:0;right:0}.mapboxgl-ctrl-bottom-left{bottom:0;left:0}.mapboxgl-ctrl-bottom-right{right:0;bottom:0}.mapboxgl-ctrl{clear:both;pointer-events:auto;transform:translate(0)}.mapboxgl-ctrl-top-left .mapboxgl-ctrl{margin:10px 0 0 10px;float:left}.mapboxgl-ctrl-top-right .mapboxgl-ctrl{margin:10px 10px 0 0;float:right}.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl{margin:0 0 10px 10px;float:left}.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl{margin:0 10px 10px 0;float:right}.mapboxgl-ctrl-group{border-radius:4px;background:#fff}.mapboxgl-ctrl-group:not(:empty){-moz-box-shadow:0 0 2px rgba(0,0,0,.1);-webkit-box-shadow:0 0 2px rgba(0,0,0,.1);box-shadow:0 0 0 2px rgba(0,0,0,.1)}@media (-ms-high-contrast:active){.mapboxgl-ctrl-group:not(:empty){box-shadow:0 0 0 2px ButtonText}}.mapboxgl-ctrl-group button{width:29px;height:29px;display:block;padding:0;outline:none;border:0;box-sizing:border-box;background-color:transparent;cursor:pointer}.mapboxgl-ctrl-group button+button{border-top:1px solid #ddd}.mapboxgl-ctrl button .mapboxgl-ctrl-icon{display:block;width:100%;height:100%;background-repeat:no-repeat;background-position:50%}@media (-ms-high-contrast:active){.mapboxgl-ctrl-icon{background-color:transparent}.mapboxgl-ctrl-group button+button{border-top:1px solid ButtonText}}.mapboxgl-ctrl button::-moz-focus-inner{border:0;padding:0}.mapboxgl-ctrl-group button:focus{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl button:disabled{cursor:not-allowed}.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon{opacity:.25}.mapboxgl-ctrl button:not(:disabled):hover{background-color:rgba(0,0,0,.05)}.mapboxgl-ctrl-group button:focus:focus-visible{box-shadow:0 0 2px 2px #0096ff}.mapboxgl-ctrl-group button:focus:not(:focus-visible){box-shadow:none}.mapboxgl-ctrl-group button:focus:first-child{border-radius:4px 4px 0 0}.mapboxgl-ctrl-group button:focus:last-child{border-radius:0 0 4px 4px}.mapboxgl-ctrl-group button:focus:only-child{border-radius:inherit}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E")}}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting .mapboxgl-ctrl-icon{-webkit-animation:mapboxgl-spin 2s linear infinite;-moz-animation:mapboxgl-spin 2s infinite linear;-o-animation:mapboxgl-spin 2s infinite linear;-ms-animation:mapboxgl-spin 2s infinite linear;animation:mapboxgl-spin 2s linear infinite}@media (-ms-high-contrast:active){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E")}.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E")}}@-webkit-keyframes mapboxgl-spin{0%{-webkit-transform:rotate(0deg)}to{-webkit-transform:rotate(1turn)}}@-moz-keyframes mapboxgl-spin{0%{-moz-transform:rotate(0deg)}to{-moz-transform:rotate(1turn)}}@-o-keyframes mapboxgl-spin{0%{-o-transform:rotate(0deg)}to{-o-transform:rotate(1turn)}}@-ms-keyframes mapboxgl-spin{0%{-ms-transform:rotate(0deg)}to{-ms-transform:rotate(1turn)}}@keyframes mapboxgl-spin{0%{transform:rotate(0deg)}to{transform:rotate(1turn)}}a.mapboxgl-ctrl-logo{width:88px;height:23px;margin:0 0 -4px -4px;display:block;background-repeat:no-repeat;cursor:pointer;overflow:hidden;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg opacity='.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg opacity='.9' fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E")}a.mapboxgl-ctrl-logo.mapboxgl-compact{width:23px}@media (-ms-high-contrast:active){a.mapboxgl-ctrl-logo{background-color:transparent;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E")}}@media (-ms-high-contrast:black-on-white){a.mapboxgl-ctrl-logo{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/svg%3E")}}.mapboxgl-ctrl.mapboxgl-ctrl-attrib{padding:0 5px;background-color:hsla(0,0%,100%,.5);margin:0}@media screen{.mapboxgl-ctrl-attrib.mapboxgl-compact{min-height:20px;padding:0;margin:10px;position:relative;background-color:#fff;border-radius:3px 12px 12px 3px}.mapboxgl-ctrl-attrib.mapboxgl-compact:hover{padding:2px 24px 2px 4px;visibility:visible;margin-top:6px}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:hover,.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:hover{padding:2px 4px 2px 24px;border-radius:12px 3px 3px 12px}.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner{display:none}.mapboxgl-ctrl-attrib.mapboxgl-compact:hover .mapboxgl-ctrl-attrib-inner{display:block}.mapboxgl-ctrl-attrib.mapboxgl-compact:after{content:"";cursor:pointer;position:absolute;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");background-color:hsla(0,0%,100%,.5);width:24px;height:24px;box-sizing:border-box;border-radius:12px}.mapboxgl-ctrl-bottom-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;right:0}.mapboxgl-ctrl-top-right>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{top:0;right:0}.mapboxgl-ctrl-top-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{top:0;left:0}.mapboxgl-ctrl-bottom-left>.mapboxgl-ctrl-attrib.mapboxgl-compact:after{bottom:0;left:0}}@media screen and (-ms-high-contrast:active){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E")}}@media screen and (-ms-high-contrast:black-on-white){.mapboxgl-ctrl-attrib.mapboxgl-compact:after{background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E")}}.mapboxgl-ctrl-attrib a{color:rgba(0,0,0,.75);text-decoration:none}.mapboxgl-ctrl-attrib a:hover{color:inherit;text-decoration:underline}.mapboxgl-ctrl-attrib .mapbox-improve-map{font-weight:700;margin-left:2px}.mapboxgl-attrib-empty{display:none}.mapboxgl-ctrl-scale{background-color:hsla(0,0%,100%,.75);font-size:10px;border:2px solid #333;border-top:#333;padding:0 5px;color:#333;box-sizing:border-box}.mapboxgl-popup{position:absolute;top:0;left:0;display:-webkit-flex;display:flex;will-change:transform;pointer-events:none}.mapboxgl-popup-anchor-top,.mapboxgl-popup-anchor-top-left,.mapboxgl-popup-anchor-top-right{-webkit-flex-direction:column;flex-direction:column}.mapboxgl-popup-anchor-bottom,.mapboxgl-popup-anchor-bottom-left,.mapboxgl-popup-anchor-bottom-right{-webkit-flex-direction:column-reverse;flex-direction:column-reverse}.mapboxgl-popup-anchor-left{-webkit-flex-direction:row;flex-direction:row}.mapboxgl-popup-anchor-right{-webkit-flex-direction:row-reverse;flex-direction:row-reverse}.mapboxgl-popup-tip{width:0;height:0;border:10px solid transparent;z-index:1}.mapboxgl-popup-anchor-top .mapboxgl-popup-tip{-webkit-align-self:center;align-self:center;border-top:none;border-bottom-color:#fff}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip{-webkit-align-self:flex-start;align-self:flex-start;border-top:none;border-left:none;border-bottom-color:#fff}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip{-webkit-align-self:flex-end;align-self:flex-end;border-top:none;border-right:none;border-bottom-color:#fff}.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip{-webkit-align-self:center;align-self:center;border-bottom:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip{-webkit-align-self:flex-start;align-self:flex-start;border-bottom:none;border-left:none;border-top-color:#fff}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip{-webkit-align-self:flex-end;align-self:flex-end;border-bottom:none;border-right:none;border-top-color:#fff}.mapboxgl-popup-anchor-left .mapboxgl-popup-tip{-webkit-align-self:center;align-self:center;border-left:none;border-right-color:#fff}.mapboxgl-popup-anchor-right .mapboxgl-popup-tip{-webkit-align-self:center;align-self:center;border-right:none;border-left-color:#fff}.mapboxgl-popup-close-button{position:absolute;right:0;top:0;border:0;border-radius:0 3px 0 0;cursor:pointer;background-color:transparent}.mapboxgl-popup-close-button:hover{background-color:rgba(0,0,0,.05)}.mapboxgl-popup-content{position:relative;background:#fff;border-radius:3px;box-shadow:0 1px 2px rgba(0,0,0,.1);padding:10px 10px 15px;pointer-events:auto}.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content{border-top-left-radius:0}.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content{border-top-right-radius:0}.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content{border-bottom-left-radius:0}.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content{border-bottom-right-radius:0}.mapboxgl-popup-track-pointer{display:none}.mapboxgl-popup-track-pointer *{pointer-events:none;user-select:none}.mapboxgl-map:hover .mapboxgl-popup-track-pointer{display:flex}.mapboxgl-map:active .mapboxgl-popup-track-pointer{display:none}.mapboxgl-marker{position:absolute;top:0;left:0;will-change:transform}.mapboxgl-user-location-dot,.mapboxgl-user-location-dot:before{background-color:#1da1f2;width:15px;height:15px;border-radius:50%}.mapboxgl-user-location-dot:before{content:"";position:absolute;-webkit-animation:mapboxgl-user-location-dot-pulse 2s infinite;-moz-animation:mapboxgl-user-location-dot-pulse 2s infinite;-ms-animation:mapboxgl-user-location-dot-pulse 2s infinite;animation:mapboxgl-user-location-dot-pulse 2s infinite}.mapboxgl-user-location-dot:after{border-radius:50%;border:2px solid #fff;content:"";height:19px;left:-2px;position:absolute;top:-2px;width:19px;box-sizing:border-box;box-shadow:0 0 3px rgba(0,0,0,.35)}@-webkit-keyframes mapboxgl-user-location-dot-pulse{0%{-webkit-transform:scale(1);opacity:1}70%{-webkit-transform:scale(3);opacity:0}to{-webkit-transform:scale(1);opacity:0}}@-ms-keyframes mapboxgl-user-location-dot-pulse{0%{-ms-transform:scale(1);opacity:1}70%{-ms-transform:scale(3);opacity:0}to{-ms-transform:scale(1);opacity:0}}@keyframes mapboxgl-user-location-dot-pulse{0%{transform:scale(1);opacity:1}70%{transform:scale(3);opacity:0}to{transform:scale(1);opacity:0}}.mapboxgl-user-location-dot-stale{background-color:#aaa}.mapboxgl-user-location-dot-stale:after{display:none}.mapboxgl-user-location-accuracy-circle{background-color:rgba(29,161,242,.2);width:1px;height:1px;border-radius:100%}.mapboxgl-crosshair,.mapboxgl-crosshair .mapboxgl-interactive,.mapboxgl-crosshair .mapboxgl-interactive:active{cursor:crosshair}.mapboxgl-boxzoom{position:absolute;top:0;left:0;width:0;height:0;background:#fff;border:2px dotted #202020;opacity:.5}@media print{.mapbox-improve-map{display:none}} -------------------------------------------------------------------------------- /static/sampleSurveyOutput/spans.geojson: -------------------------------------------------------------------------------- 1 | { 2 | "type": "FeatureCollection", 3 | "features": [{ 4 | "type": "Feature", 5 | "geometry": { 6 | "type": "LineString", 7 | "coordinates": [ 8 | [-78.63683641614065, 35.78743367205966], 9 | [-78.63685154789381, 35.78696438733452] 10 | ] 11 | }, 12 | "properties": { 13 | "created_at": 1592427435030, 14 | "cwheelid": "", 15 | "shst_ref_id": "879d13db6c49a2d3f5e08ff149b3e09c", 16 | "ref_side": "right", 17 | "ref_len": 152.82317092006875, 18 | "srv_dist": 153.6, 19 | "label": "Parking", 20 | "dst_st": 7.3, 21 | "dst_end": 59.5, 22 | "images": "[{\"url\":\"http://placekitten.com/500/600\",\"geometry\":{\"type\":\"Position\",\"distance\":34.1}}]" 23 | } 24 | }, { 25 | "type": "Feature", 26 | "geometry": { 27 | "type": "LineString", 28 | "coordinates": [ 29 | [-78.63683995269666, 35.78732399248805], 30 | [-78.63684186591142, 35.7872646576378] 31 | ] 32 | }, 33 | "properties": { 34 | "created_at": 1592427435030, 35 | "cwheelid": "", 36 | "shst_ref_id": "879d13db6c49a2d3f5e08ff149b3e09c", 37 | "ref_side": "right", 38 | "ref_len": 152.82317092006875, 39 | "srv_dist": 153.6, 40 | "label": "Loading", 41 | "dst_st": 19.5, 42 | "dst_end": 26.1, 43 | "images": "[{\"url\":\"http://placekitten.com/500/600\",\"geometry\":{\"type\":\"Position\",\"distance\":24.5}}]" 44 | } 45 | }, { 46 | "type": "Feature", 47 | "geometry": { 48 | "type": "LineString", 49 | "coordinates": [ 50 | [-78.63685864990026, 35.78674412917742], 51 | [-78.63686717225603, 35.78647981938836] 52 | ] 53 | }, 54 | "properties": { 55 | "created_at": 1592427435030, 56 | "cwheelid": "", 57 | "shst_ref_id": "879d13db6c49a2d3f5e08ff149b3e09c", 58 | "ref_side": "right", 59 | "ref_len": 152.82317092006875, 60 | "srv_dist": 153.6, 61 | "label": "Paint", 62 | "dst_st": 84, 63 | "dst_end": 113.4, 64 | "images": "[{\"url\":\"http://placekitten.com/500/600\",\"geometry\":{\"type\":\"Position\",\"distance\":93.1}},{\"url\":\"http://placekitten.com/500/600\",\"geometry\":{\"type\":\"Position\",\"distance\":99.9}}]" 65 | } 66 | }, { 67 | "type": "Feature", 68 | "geometry": { 69 | "type": "LineString", 70 | "coordinates": [ 71 | [-78.6368697231542, 35.786400706254106], 72 | [-78.6368775207547, 35.78615887178655] 73 | ] 74 | }, 75 | "properties": { 76 | "created_at": 1592427435030, 77 | "cwheelid": "", 78 | "shst_ref_id": "879d13db6c49a2d3f5e08ff149b3e09c", 79 | "ref_side": "right", 80 | "ref_len": 152.82317092006875, 81 | "srv_dist": 153.6, 82 | "label": "Curb cut", 83 | "dst_st": 122.2, 84 | "dst_end": 149.1, 85 | "images": "[{\"url\":\"http://placekitten.com/500/600\",\"geometry\":{\"type\":\"Position\",\"distance\":135.7}}]" 86 | } 87 | }] 88 | } -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | :root{ 2 | --completeColor: #666; 3 | --defaultColor: steelblue; 4 | --activeColor: orangered; 5 | } 6 | 7 | body, html { 8 | overflow:hidden; 9 | width:100%; 10 | height:100%; 11 | position:fixed; 12 | font-family: BlinkMacSystemFont,-apple-system,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,"Fira Sans","Droid Sans","Helvetica Neue",Helvetica,Arial,sans-serif; 13 | /*font-size:18px;*/ 14 | } 15 | 16 | #topBar { 17 | height:60px; 18 | background:var(--defaultColor); 19 | color:white; 20 | position:fixed; 21 | width:100%; 22 | } 23 | 24 | #title { 25 | position: absolute; 26 | text-align: center; 27 | width: 100%; 28 | line-height: 3em; 29 | pointer-events: none 30 | } 31 | 32 | .scroll-drawer { 33 | position: absolute; 34 | width: 100%; 35 | bottom:80px; 36 | } 37 | 38 | .scroll-inner { 39 | overflow-y: scroll; 40 | height:100%; 41 | } 42 | .scroll-drawer::-webkit-scrollbar{ 43 | -webkit-appearance: none; 44 | } 45 | 46 | .scroll-drawer::-webkit-scrollbar-thumb { 47 | border-radius: 5px; 48 | background-color: rgba(0, 0, 0, 0.5); 49 | border:2px solid white; 50 | } 51 | 52 | .scroll-drawer::-webkit-scrollbar:vertical { 53 | background:none; 54 | width:10px; 55 | } 56 | 57 | #scrollBar { 58 | position:absolute; 59 | right:0; 60 | height:100%; 61 | width: 0vw; 62 | z-index: 99 63 | } 64 | 65 | #scrollThumb { 66 | width:100%; 67 | height:100%; 68 | background:purple; 69 | } 70 | .bottomButton { 71 | height:80px; 72 | text-align:center; 73 | width:100%; 74 | background:#333; 75 | color:white; 76 | font-size: 1.25em; 77 | line-height: 4em; 78 | } 79 | 80 | /*mode slider functionality*/ 81 | 82 | #modes[currentMode='selectStreet'], 83 | #modes[currentMode='selectDirection'] { 84 | transform:none; 85 | } 86 | 87 | #modes[currentMode='rolling'] { 88 | transform: translateX(-25%); 89 | } 90 | 91 | #modes[currentMode='addFeature'] { 92 | transform: translateX(-50%); 93 | } 94 | 95 | #modes { 96 | top:60px; 97 | bottom:0; 98 | position:absolute; 99 | width:400%; 100 | transition:transform 0.2s; 101 | overflow:hidden; 102 | bottom:0px; 103 | } 104 | 105 | .mode { 106 | width:25%; 107 | float:left; 108 | height:100%; 109 | display:inline; 110 | position:relative; 111 | background:#f6f6f6; 112 | } 113 | 114 | /* curb side/direction selection view*/ 115 | #modes[currentMode='selectDirection'] #mapModal { 116 | transform:translateY(0%); 117 | } 118 | 119 | /*map view*/ 120 | #map { 121 | width: 100%; 122 | position: absolute; 123 | top: 0px; 124 | bottom: 0px; 125 | } 126 | 127 | #mapModal { 128 | width: 100%; 129 | bottom: 0px; 130 | position:absolute; 131 | z-index: 99; 132 | transform:translateY(100%); 133 | transition:transform 0.5s; 134 | } 135 | 136 | /*surveying view*/ 137 | 138 | #features { 139 | overflow: scroll; 140 | } 141 | 142 | #backArrow { 143 | font-size:10vh; 144 | } 145 | 146 | .entry { 147 | padding:4vw 5vw; 148 | border-bottom:1px solid #ddd; 149 | transition: transform 0.2s; 150 | background:white; 151 | overflow:hidden; 152 | } 153 | 154 | #rollDelta { 155 | display:none; 156 | } 157 | 158 | #master .entry{ 159 | border-bottom:3px solid var(--defaultColor); 160 | } 161 | 162 | .hidden { 163 | display:none !important; 164 | } 165 | 166 | .halfButton { 167 | text-align:center; 168 | margin: 5vw; 169 | background: #fff; 170 | padding: 5vw; 171 | width:40%; 172 | white-space: nowrap; 173 | border-radius:5px; 174 | } 175 | 176 | .featureAction { 177 | text-align:center; 178 | text-transform: capitalize; 179 | /*color:#999;*/ 180 | } 181 | 182 | .featureActions { 183 | overflow:hidden; 184 | display:none !important; 185 | } 186 | 187 | .active .featureActions { 188 | display:flex !important; 189 | } 190 | 191 | /*rolling state*/ 192 | 193 | .isRolling .wheel { 194 | transform: translate(-50%, -50%); 195 | } 196 | 197 | .isRolling #rollDelta { 198 | display:inline-block; 199 | } 200 | 201 | /*progress bar*/ 202 | 203 | .progressBar { 204 | height:12px; 205 | position:relative; 206 | } 207 | 208 | .track { 209 | background:#e9e9e9; 210 | height:4px; 211 | width:100%; 212 | margin-top:6px; 213 | transform:translateY(-50%); 214 | border-radius: 2px; 215 | position: absolute; 216 | } 217 | 218 | @keyframes spin { 219 | 0% {transform:rotate(0deg)} 220 | 100% {transform:rotate(360deg)} 221 | } 222 | 223 | .wheel { 224 | position: absolute; 225 | border: 2px solid var(--defaultColor); 226 | border-radius: 50%; 227 | margin-top: 6px; 228 | transform: translate(-50%, -50%) scale(0); 229 | background:white; 230 | transition:transform 0.5s; 231 | } 232 | 233 | .spoke { 234 | height: 2px; 235 | width: 6.5px; 236 | margin:5px 5.5px 5px 0px; 237 | background: var(--defaultColor); 238 | animation: spin; 239 | animation-duration: 1s; 240 | animation-iteration-count: infinite; 241 | animation-timing-function: linear; 242 | transform-origin: right; 243 | } 244 | 245 | .span { 246 | width:0%; 247 | transform-origin:left; 248 | position:absolute; 249 | top:0; 250 | border-radius:6px; 251 | border:6px solid var(--defaultColor); 252 | transform:translateX(-6px); 253 | } 254 | 255 | #master .span { 256 | padding:2px; 257 | border-radius:2px; 258 | margin-top:6px; 259 | transform:translateY(-50%); 260 | border: none; 261 | background: var(--defaultColor); 262 | } 263 | 264 | .position { 265 | transform: translateX(-70.7%) rotateZ(45deg); 266 | width: 12px; 267 | height: 12px; 268 | position: absolute; 269 | border: 3px solid var(--completeColor); 270 | background: white; 271 | border-radius:2px; 272 | } 273 | 274 | .entry.complete .span { 275 | border: 6px solid var(--completeColor); 276 | border-radius:2px; 277 | } 278 | 279 | .dot { 280 | width:10px; 281 | height:10px; 282 | background:white; 283 | border-radius:50%; 284 | z-index:99; 285 | margin-top:6px; 286 | position:absolute; 287 | transform: translate(-50%, -50%); 288 | border: 2px solid var(--defaultColor); 289 | } 290 | 291 | 292 | /*gear icon*/ 293 | 294 | .icon { 295 | width: 1em; 296 | overflow: visible; 297 | vertical-align: -.125em; 298 | } 299 | 300 | .fa-cog { 301 | transform-origin: center; 302 | transition:transform 0.25s; 303 | opacity:0.75; 304 | filter:grayscale(1); 305 | } 306 | 307 | .active .fa-cog { 308 | transform:rotate(90deg); 309 | color:var(--defaultColor); 310 | opacity:1; 311 | filter:grayscale(0); 312 | } 313 | 314 | 315 | /*styling for completed entries*/ 316 | 317 | .complete { 318 | background: #f6f6f6; 319 | } 320 | 321 | .complete .dot { 322 | border: 2px solid var(--completeColor); 323 | } 324 | 325 | .complete .featureName:after { 326 | content: 'COMPLETE'; 327 | background: #ddd; 328 | color: black; 329 | margin-left: 10px; 330 | padding: 1px 4px; 331 | font-size: 0.75em; 332 | opacity: 0.5; 333 | border-radius: 2px; 334 | display: inline-block; 335 | transform:translateY(-10%); 336 | } 337 | 338 | .complete .spanLength { 339 | color: var(--completeColor); 340 | } 341 | 342 | -------------------------------------------------------------------------------- /switch-to-ap.sh: -------------------------------------------------------------------------------- 1 | # 1. configure local wifi network in /etc/wpa_suplicant/wpa_suplicant.conf 2 | sudo cp /home/pi/curb-wheel/config/wpa_suplicant.conf /etc/wpa_suplicant/wpa_suplicant.conf 3 | 4 | # 3. swap in backup of AP DHCP config 5 | sudo cp /etc/raspap/backups/dhcpcd.conf.ap /etc/dhcpcd.conf 6 | # 4. disable raspapd service 7 | sudo systemctl enable raspapd.service 8 | -------------------------------------------------------------------------------- /switch-to-wifi.sh: -------------------------------------------------------------------------------- 1 | 2 | # 1. configure local wifi network in /etc/wpa_suplicant/wpa_suplicant.conf 3 | sudo cp /home/pi/curb-wheel/config/wpa_suplicant.conf /etc/wpa_suplicant/wpa_suplicant.conf 4 | 5 | # 2. swap in backup of original wifi DHCP config 6 | sudo cp /etc/raspap/backups/dhcpcd.conf /etc/dhcpcd.conf 7 | # 3. disable raspapd service 8 | sudo systemctl disable raspapd.service 9 | -------------------------------------------------------------------------------- /templates/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | curbwheel 6 | 7 | 8 |

404: Page not found!

9 | 10 | 11 | -------------------------------------------------------------------------------- /templates/admin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CurbWheel Admin 7 | 8 | 9 | 10 |
11 |
12 |

13 | CurbWheel Admin 14 |

15 | 16 |
17 |

18 | Current version: 19 |

20 | 21 | 22 |
23 |

24 |
25 | 26 |
27 |

28 | Current OSM file: 29 |

30 | Processing...
31 | 32 | 33 |
34 |

35 |

36 | Current MBTiles file: 37 |

38 | Processing...
39 | 40 | 41 |
42 |

43 |
44 | 45 |
46 |

47 |

48 | 52 | 56 |
57 | 58 | 59 |


60 |
61 | 62 |
63 |
64 | 65 |
66 |
67 |

68 | Export surveys 69 |

70 | Download Data 71 |
72 |
73 |

74 | Reset surveys 75 |

76 | 77 |
78 |
79 | 80 |
81 |
82 | 83 | 242 | 243 | 244 | 245 | -------------------------------------------------------------------------------- /templates/digitizer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Digitizer 6 | 7 | 8 | 9 | 68 | 69 | 70 |
71 |
72 | 73 | 74 | 75 | 76 | 77 | 78 | 91 | 92 | -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | CurbWheel 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 17 |
18 | 19 | 20 | 21 | 22 |
Select a Street
23 |
24 |
25 |
26 |
27 |
28 |
29 |
Other side
Other direction
30 |
31 |
32 | Start Survey 33 |
34 |
35 |
36 |
37 |
38 |
39 |
Surveying 40 | 41 | foo 42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | 51 | 52 | 53 | 0m of ~m 54 | 55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | 64 |
65 |

66 | 67 | 68 | 69 | Add a curb feature 70 |

71 |
72 |
73 |
74 |
75 |
Reset
76 |
Complete
77 |
78 |
79 |
80 |
81 | 82 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 97 | 98 | -------------------------------------------------------------------------------- /test/fixtures/dc.osm.pbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/dc.osm.pbf -------------------------------------------------------------------------------- /test/fixtures/honolulu.osm.pbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/honolulu.osm.pbf -------------------------------------------------------------------------------- /test/fixtures/nyc.osm.pbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/nyc.osm.pbf -------------------------------------------------------------------------------- /test/fixtures/oakland.osm.pbf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/oakland.osm.pbf -------------------------------------------------------------------------------- /test/fixtures/sign-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/sign-1.jpg -------------------------------------------------------------------------------- /test/fixtures/sign-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/sign-2.jpg -------------------------------------------------------------------------------- /test/fixtures/sign-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/sign-3.jpg -------------------------------------------------------------------------------- /test/fixtures/sign-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sharedstreets/curb-wheel-archive/27fe2cecd088a2a465ad82beab9d8de551cadea5/test/fixtures/sign-4.png -------------------------------------------------------------------------------- /test/graph.test.js: -------------------------------------------------------------------------------- 1 | const test = require("tap").test; 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const promisify = require("util").promisify; 5 | const rimraf = require("rimraf"); 6 | const Graph = require("../src/graph"); 7 | 8 | const readFileAsync = promisify(fs.readFile); 9 | const writeFileAsync = promisify(fs.writeFile); 10 | const unlinkAsync = promisify(fs.unlink); 11 | 12 | test("graph", async (t) => { 13 | const pbf = path.join(__dirname, "./fixtures/honolulu.osm.pbf"); 14 | 15 | let graph = new Graph(); 16 | 17 | await graph.extract(pbf); 18 | 19 | t.equal(graph.streets.length, 32577, "found correct number of streets"); 20 | 21 | t.equal(graph.refs.size, 65138, "found correct number of refs"); 22 | let ref = "021d9d867cee1714882108b20dfdab80"; 23 | t.true(graph.refs.has(ref), "graph has expected ref in lookup"); 24 | let id = graph.refs.get(ref); 25 | t.equal(id, 9, "stored refs link to street indexes"); 26 | let street = graph.streets[id]; 27 | t.equal(street.properties.forward, ref, "street has expected forward ref"); 28 | 29 | t.equal( 30 | JSON.stringify(graph.center), 31 | JSON.stringify([-157.9297923506125, 21.38802573823529]), 32 | "calculated center" 33 | ); 34 | 35 | t.equal( 36 | JSON.stringify(graph.bounds), 37 | JSON.stringify([ 38 | -158.27079110000003, 39 | 21.255709600000003, 40 | -157.65507910000002, 41 | 21.704852900000002, 42 | ]), 43 | "calculated bounds" 44 | ); 45 | 46 | let results = await graph.query([ 47 | -157.88078784942627, 48 | 21.329380640899263, 49 | -157.87932872772217, 50 | 21.330275097830327, 51 | ]); 52 | t.equal(results.length, 8, "built a spatial index"); 53 | 54 | let file = path.join(__dirname, "./fixtures/graph.json"); 55 | await graph.save(file); 56 | let raw = (await readFileAsync(file)).toString(); 57 | let data = JSON.parse(raw); 58 | t.equal(data.streets.length, 32577, "save graph"); 59 | delete data; 60 | delete raw; 61 | 62 | let copy = new Graph(); 63 | t.false(copy.loaded, "copy not loaded"); 64 | await copy.load(file); 65 | t.true(copy.loaded, "copy loaded"); 66 | t.equal(copy.streets.length, graph.streets.length, "load graph - streets"); 67 | t.equal( 68 | JSON.stringify(copy.bounds), 69 | JSON.stringify(graph.bounds), 70 | "load graph - bounds" 71 | ); 72 | t.equal( 73 | JSON.stringify(copy.center), 74 | JSON.stringify(graph.center), 75 | "load graph - center" 76 | ); 77 | let copyResults = await copy.query([ 78 | -157.88078784942627, 79 | 21.329380640899263, 80 | -157.87932872772217, 81 | 21.330275097830327, 82 | ]); 83 | t.equal( 84 | JSON.stringify(copyResults), 85 | JSON.stringify(results), 86 | "load graph - spatial index" 87 | ); 88 | 89 | await unlinkAsync(file); 90 | 91 | t.done(); 92 | }); 93 | -------------------------------------------------------------------------------- /test/server.test.js: -------------------------------------------------------------------------------- 1 | const test = require("tap").test; 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const util = require("util"); 5 | const request = require("request"); 6 | const rimraf = require("rimraf"); 7 | const app = require("../src/server"); 8 | 9 | request.post = util.promisify(request.post); 10 | request.get = util.promisify(request.get); 11 | 12 | test("server", async (t) => { 13 | let server = await app(); 14 | 15 | t.ok(server, "app server created"); 16 | 17 | let pbf = fs.createReadStream( 18 | path.join(__dirname, "./fixtures/honolulu.osm.pbf") 19 | ); 20 | 21 | let res = await request 22 | .post({ 23 | url: "http://127.0.0.1:8081/pbf", 24 | formData: { 25 | pbf: pbf, 26 | }, 27 | }) 28 | .catch((err) => { 29 | if (err) throw err; 30 | }); 31 | 32 | t.equal(res.statusCode, 200, "returned a valid response code"); 33 | 34 | let image1 = fs.createReadStream( 35 | path.join(__dirname, "./fixtures/sign-1.jpg") 36 | ); 37 | let image2 = fs.createReadStream( 38 | path.join(__dirname, "./fixtures/sign-2.jpg") 39 | ); 40 | let image3 = fs.createReadStream( 41 | path.join(__dirname, "./fixtures/sign-3.jpg") 42 | ); 43 | let image4 = fs.createReadStream( 44 | path.join(__dirname, "./fixtures/sign-4.png") 45 | ); 46 | 47 | let upload1 = await request 48 | .post({ 49 | url: "http://127.0.0.1:8081/photo", 50 | formData: { 51 | image: image1, 52 | }, 53 | }) 54 | .catch((err) => { 55 | if (err) throw err; 56 | }); 57 | let upload2 = await request 58 | .post({ 59 | url: "http://127.0.0.1:8081/photo", 60 | formData: { 61 | image: image2, 62 | }, 63 | }) 64 | .catch((err) => { 65 | if (err) throw err; 66 | }); 67 | let upload3 = await request 68 | .post({ 69 | url: "http://127.0.0.1:8081/photo", 70 | formData: { 71 | image: image3, 72 | }, 73 | }) 74 | .catch((err) => { 75 | if (err) throw err; 76 | }); 77 | let upload4 = await request 78 | .post({ 79 | url: "http://127.0.0.1:8081/photo", 80 | formData: { 81 | image: image4, 82 | }, 83 | }) 84 | .catch((err) => { 85 | if (err) throw err; 86 | }); 87 | 88 | t.equal( 89 | upload1.statusCode, 90 | 200, 91 | "upload 1 (jpg) returned valid status code 200" 92 | ); 93 | t.equal( 94 | upload2.statusCode, 95 | 200, 96 | "upload 2 (jpg) returned valid status code 200" 97 | ); 98 | t.equal( 99 | upload3.statusCode, 100 | 200, 101 | "upload 3 (jpg) returned valid status code 200" 102 | ); 103 | t.equal( 104 | upload4.statusCode, 105 | 200, 106 | "upload 4 (png) returned valid status code 200" 107 | ); 108 | 109 | let survey = { ok: 1 }; 110 | let ref = "123"; 111 | let saved = await request.post({ 112 | url: "http://127.0.0.1:8081/surveys/" + ref, 113 | headers: { "Content-Type": "application/json" }, 114 | body: JSON.stringify(survey), 115 | }); 116 | 117 | t.equal(saved.statusCode, 200, "survey save returned valid status code 200"); 118 | 119 | let surveysResponse = await request.get( 120 | "http://127.0.0.1:8081/surveys/" + ref 121 | ); 122 | 123 | t.equal( 124 | surveysResponse.statusCode, 125 | 200, 126 | "surveys get returned valid status code 200" 127 | ); 128 | t.equal( 129 | surveysResponse.body, 130 | JSON.stringify([survey]), 131 | "surveys matched uploaded data" 132 | ); 133 | 134 | // clean up 135 | server.close(); 136 | rimraf.sync(path.join(__dirname, "../static/images/survey")); 137 | 138 | t.ok("server closed gracefully"); 139 | 140 | t.done(); 141 | }); 142 | -------------------------------------------------------------------------------- /upgrade_wheel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | WHEEL_KEY=./wheel.key 4 | if ! test -f "$WHEEL_KEY"; then 5 | ssh-keygen -t rsa -N '' -f wheel.key 6 | ssh-copy-id -i wheel.key pi@raspberrypi.local 7 | fi 8 | 9 | scp -i $WHEEL_KEY -r ./python pi@raspberrypi.local:/home/pi/curb-wheel/ 10 | scp -i $WHEEL_KEY -r ./src pi@raspberrypi.local:/home/pi/curb-wheel/ 11 | scp -i $WHEEL_KEY -r ./static pi@raspberrypi.local:/home/pi/curb-wheel/ 12 | scp -i $WHEEL_KEY -r ./templates pi@raspberrypi.local:/home/pi/curb-wheel/ 13 | #scp -i $WHEEL_KEY -r ./test pi@raspberrypi.local:/home/pi/curb-wheel/ 14 | scp -i $WHEEL_KEY *.sh pi@raspberrypi.local:/home/pi/curb-wheel/ 15 | --------------------------------------------------------------------------------