├── .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 |
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 |
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 |
15 |
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 |
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 |
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 |
20 | );
21 |
22 | const LinkedinIcon = (props: any) => (
23 |
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 |
133 | );
134 | }
135 |
--------------------------------------------------------------------------------