├── .eslintrc.json ├── public ├── favicon.ico ├── vercel.svg ├── thirteen.svg └── next.svg ├── postcss.config.js ├── src ├── types │ ├── ViewportProps.ts │ ├── InfoModalProps.ts │ ├── ZoomModalProps.ts │ ├── ParkingAnalysis.ts │ ├── ParkingSearchProps.ts │ └── MapProps.ts ├── utils │ ├── Space.tsx │ ├── validateViewport.ts │ ├── latLngBoundsToPolygon.ts │ └── analyzeParking.ts ├── config │ └── defaults.ts ├── pages │ ├── _document.tsx │ ├── index.tsx │ ├── _app.tsx │ └── [latitude] │ │ └── [longitude] │ │ └── [zoom] │ │ └── index.tsx ├── components │ ├── LoadingOverlay.tsx │ ├── CheckBox.tsx │ ├── ZoomModal.tsx │ ├── MainMap.tsx │ ├── Window.tsx │ └── InfoModal.tsx ├── styles │ ├── Map.css │ └── globals.css └── overpass │ └── overpass.ts ├── next.config.js ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── package.json ├── LICENSE.md └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brandonfcohen1/openparkingmap/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/types/ViewportProps.ts: -------------------------------------------------------------------------------- 1 | export interface ViewportProps { 2 | longitude: number; 3 | latitude: number; 4 | zoom: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/Space.tsx: -------------------------------------------------------------------------------- 1 | export const Space = () => { 2 | return ( 3 | <> 4 |
5 |
6 | 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /src/types/InfoModalProps.ts: -------------------------------------------------------------------------------- 1 | export interface InfoModalProps { 2 | showInfoModal: boolean; 3 | setShowInfoModal: (showZoomModal: boolean) => void; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/ZoomModalProps.ts: -------------------------------------------------------------------------------- 1 | export interface ZoomModalProps { 2 | showZoomModal: boolean; 3 | setShowZoomModal: (showZoomModal: boolean) => void; 4 | } 5 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; 9 | -------------------------------------------------------------------------------- /src/config/defaults.ts: -------------------------------------------------------------------------------- 1 | import { ViewportProps } from "@/types/ViewportProps"; 2 | 3 | export const defaultViewport: ViewportProps = { 4 | longitude: -118.482505, 5 | latitude: 34.0248477, 6 | zoom: 14, 7 | }; 8 | -------------------------------------------------------------------------------- /src/types/ParkingAnalysis.ts: -------------------------------------------------------------------------------- 1 | import { FeatureCollection } from "geojson"; 2 | import { LngLatBounds } from "mapbox-gl"; 3 | 4 | export interface ParkingAnalysis { 5 | parking: FeatureCollection; 6 | bounds: LngLatBounds; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Main, Head, NextScript } from "next/document"; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/types/ParkingSearchProps.ts: -------------------------------------------------------------------------------- 1 | export interface ParkingSearchProps { 2 | handleParkingSearch: (restrictTags: { key: string; tag: string }[]) => void; 3 | loading: boolean; 4 | parkingArea: number; 5 | windowBoundArea: number; 6 | setShowInfoModal: (show: boolean) => void; 7 | error: boolean; 8 | downloadData: () => void; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/LoadingOverlay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const LoadingOverlay = () => { 4 | return ( 5 |
6 |
7 |
8 | ); 9 | }; 10 | 11 | export default LoadingOverlay; 12 | -------------------------------------------------------------------------------- /src/styles/Map.css: -------------------------------------------------------------------------------- 1 | .map-container { 2 | position: relative; 3 | top: 0; 4 | left: 0; 5 | height: 100%; 6 | width: 100%; 7 | } 8 | 9 | 10 | .sidebarStyle { 11 | display: inline-block; 12 | position: absolute; 13 | top: 0; 14 | left: 0; 15 | margin: 12px; 16 | background-color: #404040; 17 | color: #ffffff; 18 | z-index: 1 !important; 19 | padding: 6px; 20 | font-weight: bold; 21 | } 22 | 23 | 24 | .map-page { 25 | position: fixed; 26 | height: 100%; 27 | top: 0; 28 | left: 0; 29 | bottom: 0; 30 | right: 0; 31 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/utils/validateViewport.ts: -------------------------------------------------------------------------------- 1 | export const validateViewport = (latitude: any, longitude: any, zoom: any) => { 2 | const numLatitude = Number(latitude); 3 | const numLongitude = Number(longitude); 4 | const numZoom = Number(zoom); 5 | 6 | if (isNaN(numLatitude) || numLatitude < -90 || numLatitude > 90) { 7 | return false; 8 | } 9 | 10 | if (isNaN(numLongitude) || numLongitude < -180 || numLongitude > 180) { 11 | return false; 12 | } 13 | 14 | if (isNaN(numZoom) || numZoom < 0 || numZoom > 22) { 15 | return false; 16 | } 17 | 18 | return true; 19 | }; 20 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/latLngBoundsToPolygon.ts: -------------------------------------------------------------------------------- 1 | import { LngLatBounds } from "mapbox-gl"; 2 | import { Polygon } from "geojson"; 3 | 4 | export function lngLatBoundsToPolygon(lngLatBounds: LngLatBounds): Polygon { 5 | const sw = lngLatBounds.getSouthWest(); 6 | const ne = lngLatBounds.getNorthEast(); 7 | 8 | const coordinates: number[][] = [ 9 | [sw.lng, sw.lat], 10 | [sw.lng, ne.lat], 11 | [ne.lng, ne.lat], 12 | [ne.lng, sw.lat], 13 | [sw.lng, sw.lat], 14 | ]; 15 | 16 | const polygon: Polygon = { 17 | type: "Polygon", 18 | coordinates: [coordinates], 19 | }; 20 | 21 | return polygon; 22 | } 23 | -------------------------------------------------------------------------------- /src/types/MapProps.ts: -------------------------------------------------------------------------------- 1 | import { ViewportProps } from "@/types/ViewportProps"; 2 | import { LngLatBounds } from "mapbox-gl"; 3 | import { FeatureCollection } from "geojson"; 4 | import { MapRef } from "react-map-gl"; 5 | 6 | export interface MapProps { 7 | parkingLots: FeatureCollection; 8 | loading: boolean; 9 | savedBounds: LngLatBounds | undefined; 10 | showZoomModal: boolean; 11 | showInfoModal: boolean; 12 | setShowZoomModal: (showZoomModal: boolean) => void; 13 | setShowInfoModal: (showInfoModal: boolean) => void; 14 | setBounds: (bounds: LngLatBounds) => void; 15 | viewport: ViewportProps; 16 | setViewport: (viewport: ViewportProps) => void; 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /src/components/CheckBox.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, ReactNode, SetStateAction } from "react"; 2 | 3 | export const CheckBox = ({ 4 | children, 5 | isChecked, 6 | setIsChecked, 7 | }: { 8 | children: ReactNode; 9 | isChecked: boolean; 10 | setIsChecked: Dispatch>; 11 | }) => ( 12 |
setIsChecked((isChecked) => !isChecked)} 15 | > 16 | 22 | 25 |
26 | ); 27 | -------------------------------------------------------------------------------- /src/utils/analyzeParking.ts: -------------------------------------------------------------------------------- 1 | import { area } from "@turf/turf"; 2 | import { lngLatBoundsToPolygon } from "./latLngBoundsToPolygon"; 3 | import { ParkingAnalysis } from "@/types/ParkingAnalysis"; 4 | 5 | const m2ToAcres = (m2: number) => { 6 | return m2 * 0.000247105; 7 | }; 8 | 9 | export const analyzeParking = ({ parking, bounds }: ParkingAnalysis) => { 10 | // Get total area of parking 11 | let totalParkingArea = 0; 12 | parking.features.forEach((feature) => { 13 | totalParkingArea += area(feature); 14 | }); 15 | totalParkingArea = m2ToAcres(totalParkingArea); 16 | 17 | const boundPolygon = lngLatBoundsToPolygon(bounds); 18 | const boundArea = m2ToAcres(area(boundPolygon)); 19 | 20 | return { totalParkingArea, boundArea }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { defaultViewport } from "@/config/defaults"; 4 | 5 | const Home = () => { 6 | const router = useRouter(); 7 | 8 | // Redirect to default or saved location 9 | useEffect(() => { 10 | const { longitude, latitude, zoom } = defaultViewport; 11 | 12 | const localLatitude = localStorage.getItem("latitude") as string; 13 | const localLongitude = localStorage.getItem("longitude") as string; 14 | const localZoom = localStorage.getItem("zoom") as string; 15 | 16 | if (localLatitude && localLongitude && localZoom) 17 | router.replace(`/${localLatitude}/${localLongitude}/${localZoom}`); 18 | else router.replace(`/${latitude}/${longitude}/${zoom}`); 19 | }, [router]); 20 | 21 | return
Redirecting...
; 22 | }; 23 | 24 | export default Home; 25 | -------------------------------------------------------------------------------- /src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "@/styles/globals.css"; 2 | import "@/styles/Map.css"; 3 | import "mapbox-gl/dist/mapbox-gl.css"; 4 | import type { AppProps } from "next/app"; 5 | import Script from "next/script"; 6 | import Head from "next/head"; 7 | 8 | const GoogleAnalytics = () => { 9 | return ( 10 | <> 11 | 24 | 25 | ); 26 | }; 27 | 28 | export default function App({ Component, pageProps }: AppProps) { 29 | return ( 30 | <> 31 | 32 | OpenParkingMap 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parking-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^1.7.13", 13 | "@heroicons/react": "^2.0.16", 14 | "@mapbox/mapbox-gl-geocoder": "^5.0.1", 15 | "@next/font": "13.1.6", 16 | "@turf/turf": "^6.5.0", 17 | "@types/node": "18.14.0", 18 | "@types/react": "18.0.28", 19 | "@types/react-dom": "18.0.11", 20 | "eslint": "8.34.0", 21 | "eslint-config-next": "13.1.6", 22 | "mapbox-gl": "^2.12.1", 23 | "next": "13.1.6", 24 | "react": "18.2.0", 25 | "react-dom": "18.2.0", 26 | "react-map-gl": "^7.0.21", 27 | "typescript": "4.9.5" 28 | }, 29 | "devDependencies": { 30 | "@types/mapbox__mapbox-gl-geocoder": "^4.7.3", 31 | "@types/mapbox-gl": "^2.7.10", 32 | "autoprefixer": "^10.4.13", 33 | "postcss": "^8.4.21", 34 | "tailwindcss": "^3.2.7" 35 | }, 36 | "browser": { 37 | "fs": false, 38 | "child_process": false 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Brandon Cohen 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenParkingMap 2 | 3 | [OpenParkingMap](https://www.openparkingmap.com/) 4 | 5 | This project was inspired by reading [The High Cost of Free Parking](https://www.amazon.com/High-Cost-Free-Parking-Updated/dp/193236496X), by Donald Shoup. From the description: 6 | 7 | > Planners mandate free parking to alleviate congestion but end up distorting transportation choices, debasing urban design, damaging the economy, and degrading the environment. Ubiquitous free parking helps explain why our cities sprawl on a scale fit more for cars than for people, and why American motor vehicles now consume one-eighth of the world's total oil production. But it doesn't have to be this way. 8 | 9 | [Here's](https://www.nytimes.com/2023/03/07/business/fewer-parking-spots.html) a good recent NYT article on the subject. 10 | Also, check out the [Parking Reform Network](https://parkingreform.org/)! 11 | 12 | This is a simple Nextjs app hosted on Vercel. I query the [OSM Overpass API](https://overpass-turbo.eu/) to get parking lot geometries. 13 | 14 | Feel free to contribute to the project. 15 | 16 | Get in touch with me below. 17 | 18 | [Github](https://github.com/brandonfcohen1)\ 19 | [LinkedIn](https://www.linkedin.com/in/brandonfcohen/)\ 20 | [CivilGrid](https://www.civilgrid.com/) 21 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/ZoomModal.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { Dialog } from "@headlessui/react"; 3 | import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; 4 | import { ZoomModalProps } from "@/types/ZoomModalProps"; 5 | 6 | export default function Modal({ 7 | showZoomModal, 8 | setShowZoomModal, 9 | }: ZoomModalProps) { 10 | const cancelButtonRef = useRef(null); 11 | 12 | return ( 13 | setShowZoomModal(false)} 18 | open={showZoomModal} 19 | > 20 |
21 |
22 | 23 |
24 |
25 |
26 |
31 |
32 | 36 | Zoom In 37 | 38 |
39 |

40 | Zoom in to view parking data. 41 |

42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --max-width: 1100px; 3 | --border-radius: 12px; 4 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 5 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 6 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 7 | 8 | --foreground-rgb: 0, 0, 0; 9 | --background-start-rgb: 214, 219, 220; 10 | --background-end-rgb: 255, 255, 255; 11 | 12 | --primary-glow: conic-gradient( 13 | from 180deg at 50% 50%, 14 | #16abff33 0deg, 15 | #0885ff33 55deg, 16 | #54d6ff33 120deg, 17 | #0071ff33 160deg, 18 | transparent 360deg 19 | ); 20 | --secondary-glow: radial-gradient( 21 | rgba(255, 255, 255, 1), 22 | rgba(255, 255, 255, 0) 23 | ); 24 | 25 | --tile-start-rgb: 239, 245, 249; 26 | --tile-end-rgb: 228, 232, 233; 27 | --tile-border: conic-gradient( 28 | #00000080, 29 | #00000040, 30 | #00000030, 31 | #00000020, 32 | #00000010, 33 | #00000010, 34 | #00000080 35 | ); 36 | 37 | --callout-rgb: 238, 240, 241; 38 | --callout-border-rgb: 172, 175, 176; 39 | --card-rgb: 180, 185, 188; 40 | --card-border-rgb: 131, 134, 135; 41 | } 42 | 43 | @media (prefers-color-scheme: dark) { 44 | :root { 45 | --foreground-rgb: 255, 255, 255; 46 | --background-start-rgb: 0, 0, 0; 47 | --background-end-rgb: 0, 0, 0; 48 | 49 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 50 | --secondary-glow: linear-gradient( 51 | to bottom right, 52 | rgba(1, 65, 255, 0), 53 | rgba(1, 65, 255, 0), 54 | rgba(1, 65, 255, 0.3) 55 | ); 56 | 57 | --tile-start-rgb: 2, 13, 46; 58 | --tile-end-rgb: 2, 5, 19; 59 | --tile-border: conic-gradient( 60 | #ffffff80, 61 | #ffffff40, 62 | #ffffff30, 63 | #ffffff20, 64 | #ffffff10, 65 | #ffffff10, 66 | #ffffff80 67 | ); 68 | 69 | --callout-rgb: 20, 20, 20; 70 | --callout-border-rgb: 108, 108, 108; 71 | --card-rgb: 100, 100, 100; 72 | --card-border-rgb: 200, 200, 200; 73 | } 74 | } 75 | 76 | * { 77 | box-sizing: border-box; 78 | padding: 0; 79 | margin: 0; 80 | } 81 | 82 | html, 83 | body { 84 | max-width: 100vw; 85 | overflow-x: hidden; 86 | } 87 | 88 | body { 89 | color: rgb(var(--foreground-rgb)); 90 | background: linear-gradient( 91 | to bottom, 92 | transparent, 93 | rgb(var(--background-end-rgb)) 94 | ) 95 | rgb(var(--background-start-rgb)); 96 | } 97 | 98 | a { 99 | color: inherit; 100 | text-decoration: none; 101 | } 102 | 103 | @media (prefers-color-scheme: dark) { 104 | html { 105 | color-scheme: dark; 106 | } 107 | } 108 | 109 | 110 | @tailwind base; 111 | @tailwind components; 112 | @tailwind utilities; -------------------------------------------------------------------------------- /src/overpass/overpass.ts: -------------------------------------------------------------------------------- 1 | import { LngLatBounds } from "mapbox-gl"; 2 | import { FeatureCollection, Position, Feature, Geometry } from "geojson"; 3 | 4 | export const overpassQuery = async ( 5 | bounds: LngLatBounds, 6 | restrictParkingTags: { key: string; tag: string }[] 7 | ) => { 8 | const bbox = `${bounds.getSouth()},${bounds.getWest()},${bounds.getNorth()},${bounds.getEast()}`; 9 | 10 | const body = ` 11 | [out:json][bbox:${bbox}]; 12 | ( 13 | way[amenity=parking]${restrictParkingTags 14 | .map(({ key, tag }) => `[${key}!~"${tag}"]`) 15 | .join("")}; 16 | relation[amenity=parking]${restrictParkingTags 17 | .map(({ key, tag }) => `[${key}!~"${tag}"]`) 18 | .join("")}; 19 | )->.x1; 20 | nwr.x1->.result; 21 | (.result; - .done;)->.result; 22 | .result out meta geom qt; 23 | `; 24 | 25 | const convertToGeoJSON = async (body: string): Promise => { 26 | const response = await fetch("https://overpass-api.de/api/interpreter", { 27 | body, 28 | method: "POST", 29 | }); 30 | const data = await response.json(); 31 | 32 | const geojson: FeatureCollection = { 33 | type: "FeatureCollection", 34 | features: data.elements.map((element: any): Feature => { 35 | let geometry: Geometry; 36 | 37 | if (element.type === "way") { 38 | // Single polygon 39 | geometry = { 40 | type: "Polygon", 41 | coordinates: [ 42 | element.geometry.map( 43 | (latLngObj: any) => [latLngObj.lon, latLngObj.lat] as Position 44 | ), 45 | ], 46 | }; 47 | } else if ( 48 | element.type === "relation" && 49 | element.tags.type === "multipolygon" 50 | ) { 51 | // Multipolygon 52 | const coordinates: Position[][][] = []; 53 | const outerRings: Position[][] = []; 54 | const innerRings: Position[][] = []; 55 | 56 | element.members.forEach((member: any) => { 57 | const ring = member.geometry.map( 58 | (latLngObj: any) => [latLngObj.lon, latLngObj.lat] as Position 59 | ); 60 | if (member.role === "outer") { 61 | outerRings.push(ring); 62 | } else if (member.role === "inner") { 63 | innerRings.push(ring); 64 | } 65 | }); 66 | 67 | // Assuming each multipolygon only has one outer ring for simplicity 68 | // More complex handling may be needed for multiple outer rings 69 | if (outerRings.length > 0) { 70 | coordinates.push([outerRings[0], ...innerRings]); 71 | } 72 | 73 | geometry = { 74 | type: "MultiPolygon", 75 | coordinates: coordinates, 76 | }; 77 | } else { 78 | // Default or other geometry types 79 | geometry = { 80 | type: "GeometryCollection", 81 | geometries: [], 82 | }; 83 | } 84 | 85 | return { 86 | type: "Feature", 87 | geometry: geometry, 88 | properties: element.tags, 89 | }; 90 | }), 91 | }; 92 | 93 | return geojson; 94 | }; 95 | 96 | const geojson = await convertToGeoJSON(body); 97 | return geojson; 98 | }; 99 | -------------------------------------------------------------------------------- /src/pages/[latitude]/[longitude]/[zoom]/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from "react"; 2 | import { useRouter } from "next/router"; 3 | import { LngLatBounds } from "mapbox-gl"; 4 | import { overpassQuery } from "@/overpass/overpass"; 5 | import { FeatureCollection } from "geojson"; 6 | import { Window } from "@/components/Window"; 7 | import { analyzeParking } from "@/utils/analyzeParking"; 8 | import { validateViewport } from "@/utils/validateViewport"; 9 | import { defaultViewport } from "@/config/defaults"; 10 | import { MainMap } from "@/components/MainMap"; 11 | 12 | export const MainPage = () => { 13 | const [bounds, setBounds] = useState(); 14 | const [savedBounds, setSavedBounds] = useState(); 15 | const [loading, setLoading] = useState(false); 16 | const [parkingLots, setParkingLots] = useState({ 17 | type: "FeatureCollection", 18 | features: [], 19 | } as FeatureCollection); 20 | const [parkingArea, setParkingArea] = useState(0); 21 | const [windowBoundArea, setWindowBoundArea] = useState(0); 22 | const [showZoomModal, setShowZoomModal] = useState(false); 23 | const [showInfoModal, setShowInfoModal] = useState(false); 24 | const [viewport, setViewport] = useState(defaultViewport); 25 | const [error, setError] = useState(false); 26 | const [initialized, setInitialized] = useState(false); 27 | 28 | const router = useRouter(); 29 | const { latitude, longitude, zoom } = router.query; 30 | 31 | // Init map location from URL 32 | useEffect(() => { 33 | if (initialized) return; 34 | 35 | const isValidViewport = validateViewport(latitude, longitude, zoom); 36 | if (!isValidViewport) return; 37 | 38 | setViewport({ 39 | latitude: Number(latitude), 40 | longitude: Number(longitude), 41 | zoom: Number(zoom), 42 | }); 43 | setInitialized(true); 44 | }, [initialized, latitude, longitude, setViewport, zoom]); 45 | 46 | const handleParkingSearch = async ( 47 | restrictTags: { key: string; tag: string }[] 48 | ) => { 49 | if (viewport.zoom < 13) { 50 | setShowZoomModal(true); 51 | return; 52 | } 53 | 54 | if (!bounds) { 55 | return; 56 | } 57 | 58 | setLoading(true); 59 | setSavedBounds(bounds); 60 | 61 | try { 62 | const parking = await overpassQuery(bounds, restrictTags); 63 | setParkingLots(parking); 64 | setLoading(false); 65 | 66 | const { totalParkingArea, boundArea } = analyzeParking({ 67 | parking, 68 | bounds, 69 | }); 70 | setParkingArea(totalParkingArea); 71 | setWindowBoundArea(boundArea); 72 | setError(false); 73 | } catch (e) { 74 | setLoading(false); 75 | setError(true); 76 | } 77 | }; 78 | 79 | const downloadData = () => { 80 | const parkingData = JSON.stringify(parkingLots); 81 | const parkingDataBlob = new Blob([parkingData], { 82 | type: "application/json", 83 | }); 84 | const parkingDataUrl = URL.createObjectURL(parkingDataBlob); 85 | const link = document.createElement("a"); 86 | link.href = parkingDataUrl; 87 | link.download = "parkingData.json"; 88 | document.body.appendChild(link); 89 | link.click(); 90 | document.body.removeChild(link); 91 | }; 92 | 93 | return ( 94 | <> 95 |
96 | 105 | {initialized && ( 106 | 118 | )} 119 |
120 | 121 | ); 122 | }; 123 | 124 | export default MainPage; 125 | -------------------------------------------------------------------------------- /src/components/MainMap.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useRouter } from "next/router"; 3 | import mapboxgl from "mapbox-gl"; 4 | import Map, { Source, Layer, GeolocateControl } from "react-map-gl"; 5 | import InfoModal from "./InfoModal"; 6 | import LoadingOverlay from "./LoadingOverlay"; 7 | import ZoomModal from "./ZoomModal"; 8 | import { lngLatBoundsToPolygon } from "@/utils/latLngBoundsToPolygon"; 9 | import MapboxGeocoder from "@mapbox/mapbox-gl-geocoder"; 10 | import { MapProps } from "@/types/MapProps"; 11 | import "@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css"; 12 | 13 | mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN || ""; 14 | 15 | export const MainMap = ({ 16 | parkingLots, 17 | loading, 18 | savedBounds, 19 | showZoomModal, 20 | showInfoModal, 21 | setShowZoomModal, 22 | setShowInfoModal, 23 | setBounds, 24 | viewport, 25 | setViewport, 26 | }: MapProps) => { 27 | const geocoderContainerRef = useRef(null); 28 | const geolocateControlRef = useRef(null); 29 | const [mapInstance, setMapInstance] = useState(); 30 | 31 | const router = useRouter(); 32 | 33 | useEffect(() => { 34 | if (mapInstance) { 35 | const center = mapInstance.getCenter(); 36 | const geocoder = new MapboxGeocoder({ 37 | accessToken: mapboxgl.accessToken, 38 | mapboxgl: mapboxgl, 39 | marker: false, 40 | proximity: { 41 | longitude: center.lng, 42 | latitude: center.lat, 43 | }, 44 | }); 45 | geocoder.addTo(geocoderContainerRef.current); 46 | 47 | geocoder.on("result", (e) => { 48 | mapInstance.flyTo({ 49 | center: e.result.center, 50 | zoom: 14, 51 | }); 52 | }); 53 | } 54 | }, [mapInstance]); 55 | 56 | const updateURL = (latitude: number, longitude: number, zoom: number) => { 57 | router.replace( 58 | `/${latitude.toFixed(7)}/${longitude.toFixed(7)}/${zoom.toFixed(2)}` 59 | ); 60 | }; 61 | 62 | const saveLocation = (latitude: number, longitude: number, zoom: number) => { 63 | localStorage.setItem("latitude", latitude.toFixed(7)); 64 | localStorage.setItem("longitude", longitude.toFixed(7)); 65 | localStorage.setItem("zoom", zoom.toFixed(2)); 66 | }; 67 | 68 | return ( 69 |
70 | {loading && } 71 | {showZoomModal && ( 72 | 76 | )} 77 | {showInfoModal && ( 78 | 82 | )} 83 | 84 | { 88 | setBounds(e.target.getBounds()); 89 | const latitude = e.target.getCenter().lat; 90 | const longitude = e.target.getCenter().lng; 91 | const zoom = e.target.getZoom(); 92 | setViewport({ latitude, longitude, zoom }); 93 | updateURL(latitude, longitude, zoom); 94 | saveLocation(latitude, longitude, zoom); 95 | }} 96 | onRender={(e) => setBounds(e.target.getBounds())} 97 | onLoad={(e) => setMapInstance(e.target)} 98 | > 99 |
100 | { 105 | setViewport({ 106 | ...viewport, 107 | latitude: pos.coords.latitude, 108 | longitude: pos.coords.longitude, 109 | }); 110 | }} 111 | /> 112 |
113 |
114 | 115 | 124 | 125 | {savedBounds && ( 126 | 131 | 139 | 140 | )} 141 | 142 |
143 | ); 144 | }; 145 | -------------------------------------------------------------------------------- /src/components/Window.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Disclosure } from "@headlessui/react"; 3 | import { ChevronDownIcon } from "@heroicons/react/20/solid"; 4 | import { InformationCircleIcon } from "@heroicons/react/24/outline"; 5 | import { ParkingSearchProps } from "@/types/ParkingSearchProps"; 6 | import { CheckBox } from "@/components/CheckBox"; 7 | 8 | export const Window = ({ 9 | handleParkingSearch, 10 | loading, 11 | parkingArea, 12 | windowBoundArea, 13 | setShowInfoModal, 14 | error, 15 | downloadData, 16 | }: ParkingSearchProps) => { 17 | const [excludeStreetSide, setExcludeStreetSide] = useState(false); 18 | const [excludePrivate, setExcludePrivate] = useState(false); 19 | const getRestrictedTags = () => { 20 | const restrictedTags = [{ key: "parking", tag: "underground" }]; 21 | if (excludeStreetSide) 22 | restrictedTags.push({ key: "parking", tag: "street_side" }); 23 | if (excludePrivate) restrictedTags.push({ key: "access", tag: "private" }); 24 | return restrictedTags; 25 | }; 26 | 27 | return ( 28 |
29 |
30 |

31 |
32 | OpenParkingMap 33 |
setShowInfoModal(true)} 35 | className="cursor-pointer" 36 | > 37 | 38 |
39 |
40 |

41 | 42 | {loading ? ( 43 |
44 | loading... 45 |
46 | ) : ( 47 | <> 48 |
handleParkingSearch(getRestrictedTags())} 51 | > 52 | Show At-Grade Parking 53 |
54 | 55 | 59 | Exclude street-side parking 60 | 61 | 62 | 66 | Exclude private parking 67 | 68 | 69 | )} 70 | 71 | {parkingArea > 0 && ( 72 | 73 | {({ open }) => ( 74 | <> 75 | 76 | Details 77 | 82 | 83 | 84 | At-Grade Parking: {parkingArea.toFixed(1)} ac 85 |
86 | Area in Window: {windowBoundArea.toFixed(1)} ac 87 |
88 | % of window: 89 | {((parkingArea / windowBoundArea) * 100).toFixed(1)} % 90 |
91 |
92 | 96 | Download GeoJSON 97 | 98 |
99 | 108 |
109 | 110 | )} 111 |
112 | )} 113 | {error && ( 114 |
115 | Error loading data. Please try again. 116 |
117 | )} 118 |
119 |
120 | ); 121 | }; 122 | -------------------------------------------------------------------------------- /src/components/InfoModal.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from "react"; 2 | import { Dialog } from "@headlessui/react"; 3 | import { EnvelopeIcon } from "@heroicons/react/24/outline"; 4 | import { InfoModalProps } from "@/types/InfoModalProps"; 5 | import { Space } from "@/utils/Space"; 6 | 7 | import React from "react"; 8 | 9 | const GithubIcon = (props: any) => ( 10 | 18 | 19 | 20 | ); 21 | 22 | const LinkedinIcon = (props: any) => ( 23 | 30 | 31 | 32 | ); 33 | 34 | const Link = ({ href, children }: { href: string; children: any }) => ( 35 | 41 | {children} 42 | 43 | ); 44 | 45 | export default function InfoModal({ 46 | showInfoModal, 47 | setShowInfoModal, 48 | }: InfoModalProps) { 49 | const cancelButtonRef = useRef(null); 50 | 51 | return ( 52 | setShowInfoModal(false)} 57 | open={showInfoModal} 58 | > 59 |
60 |
61 | 62 |
63 |
64 |
65 | 69 | OpenParkingMap 70 | 71 |
72 |
73 | {`OpenParkingMap is a tool for visualizing parking lots in your area using data from OpenStreetMap.`} 74 | 75 | {` This project was inspired by reading `} 76 | 77 | {` The High Cost of Free Parking`} 78 | 79 | {`, by Donald Shoup. From the description:`} 80 | 81 |
82 | {`Planners mandate free parking to alleviate 83 | congestion but end up distorting transportation 84 | choices, debasing urban design, damaging the 85 | economy, and degrading the environment. Ubiquitous 86 | free parking helps explain why our cities sprawl on 87 | a scale fit more for cars than for people, and why 88 | American motor vehicles now consume one-eighth of 89 | the world's total oil production. But it doesn't 90 | have to be this way.`} 91 |
92 |
93 | 94 | {`Here's`} 95 | {" "} 96 | {`a good recent NYT article on the subject. Also, check out `} 97 | 103 | 104 | {`The Parking Reform Network`} 105 | 106 | {"."} 107 | 108 | {`Here`} 109 | {` is a resource from Parking Report Network that gives instructions on how to add parking lots to OpenStreetMap.`} 110 | 111 | {`Get in touch with me below.`} 112 |
113 |
114 |
115 |
116 |
117 |
118 | 119 |
120 | 121 |
122 | 123 | 124 |
125 | 126 |
127 | 128 |
129 |
130 |
131 |
132 |
133 | ); 134 | } 135 | --------------------------------------------------------------------------------