diff --git a/app/layout.tsx b/app/layout.tsx index ac5e6be..a317851 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,12 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; -import './globals.css'; +import { config } from '@fortawesome/fontawesome-svg-core'; + +import '@fortawesome/fontawesome-svg-core/styles.css'; +import '@/app/globals.css'; + +config.autoAddCss = false; const geistSans = Geist({ variable: '--font-geist-sans', @@ -38,6 +43,7 @@ export default function RootLayout({ + {/* TODO: Remove this once new maps page is complete */} ({ + endpoint, + schema, + params, +}: { + endpoint: string; + schema: z.ZodType; + params: SocrataQueryParams; +}): Promise { + const { + limit = 1000, + offset = 0, + select, + where, + order, + group, + having, + q, + } = params; + + const queryParams = new URLSearchParams({ + ...(select && { $select: select }), + ...(where && { $where: where }), + ...(order && { $order: order }), + ...(group && { $group: group }), + ...(having && { $having: having }), + ...(q && { $q: q }), + $limit: limit.toString(), + $offset: offset.toString(), + }); + + const response = await fetch(`${endpoint}?${queryParams.toString()}`, { + headers: { + 'X-App-Token': SOCRATA_APP_TOKEN, + }, + }); + + if (!response.ok) { + throw new Error('API Error'); + } + + const data = await response.json(); + return schema.parse(data); +} + +export async function getProjects(params: SocrataQueryParams = {}) { + try { + return await fetchSocrataData({ + endpoint: 'https://data.ny.gov/resource/dprp-55ye.json', + schema: ProjectsSchema, + params, + }); + } catch (error) { + console.error('Failed to fetch projects:', error); + } +} + +export async function getOutages(params: SocrataQueryParams = {}) { + try { + return await fetchSocrataData({ + endpoint: 'https://data.cityofnewyork.us/resource/br6j-yp22.json', + schema: OutagesSchema, + params, + }); + } catch (error) { + console.error('Failed to fetch outages:', error); + } +} + +export async function getEVChargingStations(params: SocrataQueryParams = {}) { + try { + return await fetchSocrataData({ + endpoint: 'https://data.cityofnewyork.us/resource/fc53-9hrv.json', + schema: EVChargingStationsSchema, + params, + }); + } catch (error) { + console.error('Failed to fetch ev charging stations:', error); + } +} + +export async function getRatSightings(params: SocrataQueryParams = {}) { + try { + return await fetchSocrataData({ + endpoint: 'https://data.cityofnewyork.us/resource/3q43-55fe.json', + schema: RatSightingsSchema, + params, + }); + } catch (error) { + console.error('Failed to fetch rat sightings:', error); + } +} diff --git a/app/map/v2/page.tsx b/app/map/v2/page.tsx new file mode 100644 index 0000000..fe02e90 --- /dev/null +++ b/app/map/v2/page.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +import { + faBolt, + faBugs, + faChargingStation, + faPlugCircleXmark, +} from '@fortawesome/free-solid-svg-icons'; +import mapboxgl from 'mapbox-gl'; + +import { + getEVChargingStations, + getOutages, + getProjects, + getRatSightings, +} from '@/app/map/v2/actions'; +import { MarkerElement } from '@/components/Marker'; +import { SideBar } from '@/components/SideBar'; + +import 'mapbox-gl/dist/mapbox-gl.css'; + +export default function MapsPage() { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + + const toggleSidebar = () => { + setIsSidebarOpen(!isSidebarOpen); + }; + + const focusOnMarker = (coordinates: [number, number]) => { + mapRef.current?.flyTo({ + center: coordinates, + zoom: 14, + duration: 1500, + }); + }; + + useEffect(() => { + mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN; + + if (mapContainerRef.current) { + mapRef.current = new mapboxgl.Map({ + container: mapContainerRef.current, + style: 'mapbox://styles/mapbox/dark-v11', + center: [-74.0242, 40.6941], + zoom: 10.12, + }); + + mapRef.current.addControl(new mapboxgl.NavigationControl()); + + mapRef.current.on('click', (e) => { + const target = e.originalEvent.target as HTMLElement; + if (target.closest('.mapboxgl-marker')) { + return; + } + setIsSidebarOpen(false); + }); + } + + return () => { + mapRef.current?.remove(); + }; + }, []); + + useEffect(() => { + const loadProjects = async () => { + try { + const projects = await getProjects(); + + if (projects) { + projects.forEach((project) => { + if ( + project.georeference?.coordinates[0] && + project.georeference?.coordinates[1] + ) { + const coordinates = project.georeference.coordinates; + const marker = new mapboxgl.Marker({ + element: MarkerElement({ icon: faBolt, color: '#ccac00' }), + }) + .setLngLat(coordinates) + .addTo(mapRef.current!); + + marker.getElement().addEventListener('click', () => { + setIsSidebarOpen(true); + focusOnMarker(coordinates); + }); + } + }); + } + } catch (error) { + console.error('Error loading projects:', error); + } + }; + + loadProjects(); + }, []); + + useEffect(() => { + const loadOutages = async () => { + try { + const outages = await getOutages(); + + if (outages) { + outages.forEach((outage) => { + if (outage.latitude && outage.longitude) { + const coordinates: [number, number] = [ + outage.longitude, + outage.latitude, + ]; + const marker = new mapboxgl.Marker({ + element: MarkerElement({ + icon: faPlugCircleXmark, + color: '#ff0000', + }), + }) + .setLngLat(coordinates) + .addTo(mapRef.current!); + + marker.getElement().addEventListener('click', () => { + setIsSidebarOpen(true); + focusOnMarker(coordinates); + }); + } + }); + } + } catch (error) { + console.error('Error loading outages:', error); + } + }; + + loadOutages(); + }, []); + + useEffect(() => { + const loadEVChargingStations = async () => { + try { + const evChargingStations = await getEVChargingStations(); + + if (evChargingStations) { + evChargingStations.forEach((station) => { + if (station.latitude && station.longitude) { + const coordinates: [number, number] = [ + station.longitude, + station.latitude, + ]; + const marker = new mapboxgl.Marker({ + element: MarkerElement({ + icon: faChargingStation, + color: '#008800', + }), + }) + .setLngLat(coordinates) + .addTo(mapRef.current!); + + marker.getElement().addEventListener('click', () => { + setIsSidebarOpen(true); + focusOnMarker(coordinates); + }); + } + }); + } + } catch (error) { + console.error('Error loading EV charging stations:', error); + } + }; + + loadEVChargingStations(); + }, []); + + useEffect(() => { + const loadRatSightings = async () => { + try { + const ratSightings = await getRatSightings(); + + if (ratSightings) { + ratSightings.forEach((sighting) => { + if ( + sighting.location?.coordinates[0] && + sighting.location?.coordinates[1] + ) { + const coordinates = sighting.location.coordinates; + const marker = new mapboxgl.Marker({ + element: MarkerElement({ icon: faBugs, color: '#000000' }), + }) + .setLngLat(coordinates) + .addTo(mapRef.current!); + + marker.getElement().addEventListener('click', () => { + setIsSidebarOpen(true); + focusOnMarker(coordinates); + }); + } + }); + } + } catch (error) { + console.error('Error loading rat sightings:', error); + } + }; + + loadRatSightings(); + }, []); + + return ( + <> +
+ + + ); +} diff --git a/components/Marker.tsx b/components/Marker.tsx new file mode 100644 index 0000000..829e2ac --- /dev/null +++ b/components/Marker.tsx @@ -0,0 +1,61 @@ +import type { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { renderToString } from 'react-dom/server'; + +export function MarkerElement({ + color, + icon, +}: { + color?: string; + icon?: IconProp; +}) { + const marker = renderToString(); + const markerElement = document.createElement('div'); + markerElement.innerHTML = marker; + + return markerElement; +} + +export function Marker({ + color = '#ff0000', + icon, +}: { + color?: string; + icon?: IconProp; +}) { + return ( +
+ + + + + + + + + + + {!icon && } + + {icon && ( + + )} +
+ ); +} diff --git a/components/SideBar.tsx b/components/SideBar.tsx new file mode 100644 index 0000000..762f88a --- /dev/null +++ b/components/SideBar.tsx @@ -0,0 +1,53 @@ +import Image from 'next/image'; + +import { faXmark } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { AnimatePresence, motion } from 'motion/react'; + +export function SideBar({ + isOpen, + onClose, +}: { + isOpen: boolean; + onClose: () => void; +}) { + return ( + + {isOpen && ( + +
+
+ placeholder + +
+

Sidebar

+
+
+ )} +
+ ); +} diff --git a/package-lock.json b/package-lock.json index 97a33eb..f3f0ffa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "glenzz", "version": "0.1.0", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", "lucide-react": "^0.473.0", "mapbox": "^1.0.0-beta10", "mapbox-gl": "^3.9.3", @@ -15,11 +20,13 @@ "motion": "^11.18.1", "next": "15.1.5", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", "@ianvs/prettier-plugin-sort-imports": "^4.4.1", + "@types/mapbox-gl": "^3.4.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -309,6 +316,76 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz", + "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-regular-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz", + "integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-fontawesome": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.2.tgz", + "integrity": "sha512-EnkrprPNqI6SXJl//m29hpaNzOp1bruISWaOiRtkMi/xSvHJlzc2j2JAYS7egxt/EbjSNV/k6Xy0AQI6vB2+1g==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react": ">=16.3" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1165,6 +1242,16 @@ "@types/pbf": "*" } }, + "node_modules/@types/mapbox-gl": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/mapbox-gl/-/mapbox-gl-3.4.1.tgz", + "integrity": "sha512-NsGKKtgW93B+UaLPti6B7NwlxYlES5DpV5Gzj9F75rK5ALKsqSk15CiEHbOnTr09RGbr6ZYiCdI+59NNNcAImg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/node": { "version": "20.17.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.14.tgz", @@ -4014,7 +4101,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -4190,7 +4276,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, "license": "MIT", "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" @@ -4525,7 +4610,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5112,7 +5196,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "license": "MIT", "dependencies": { "loose-envify": "^1.4.0", @@ -5188,7 +5271,6 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, "node_modules/read-cache": { @@ -6540,6 +6622,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz", + "integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 0c26238..bdc59db 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,11 @@ "format": "prettier --write ." }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-brands-svg-icons": "^6.7.2", + "@fortawesome/free-regular-svg-icons": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-fontawesome": "^0.2.2", "lucide-react": "^0.473.0", "mapbox": "^1.0.0-beta10", "mapbox-gl": "^3.9.3", @@ -18,11 +23,13 @@ "motion": "^11.18.1", "next": "15.1.5", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "zod": "^3.24.1" }, "devDependencies": { "@eslint/eslintrc": "^3", "@ianvs/prettier-plugin-sort-imports": "^4.4.1", + "@types/mapbox-gl": "^3.4.1", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/types/evChargingStations.ts b/types/evChargingStations.ts new file mode 100644 index 0000000..f69ad77 --- /dev/null +++ b/types/evChargingStations.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; + +export const EVChargingStationSchema = z + .object({ + /** + * Acronym/Initialism of City agency or entity the station is located at + */ + agency: z.string(), + + /** + * Street number of station's address. + */ + station_name: z.string(), + + /** + * The type of charger at the station + */ + type_of_charger: z.enum([ + 'DOT Municipal Level 2 Charger', + 'DOT Municipal Level 3 Charger', + 'EV Solar Arc Charger', + 'EV Solar Canopy Charger', + 'L2 DOT Flo Curbside Charger', + 'Level 2 Charger', + 'Level 3 Fast Charger', + 'Mobile Charger', + ]), + + /** + * The # of ports at the station + */ + no_of_ports: z.coerce.number(), + + /** + * The street address of the station + */ + street: z.string().optional(), + + /** + * The city of the station's location. + */ + city: z.string().optional(), + + /** + * ZIP code of the station's location. + */ + postcode: z.coerce.number().optional(), + + /** + * New York City's boroughs are five county-level administrative divisions, + * with each one also being a state county. (Manhattan - New York County; + * Bronx - Bronx County; Brooklyn - Kings County; Queens - Queens County; + * Staten Island - Richmond County). NYC's boroughs have existed since the + * consolidation of the city in 1898. + */ + borough: z + .enum([ + 'Bronx', + 'Brooklyn', + 'Manhattan', + 'Queens', + 'Staten Island', + 'Upstate', + ]) + .optional(), + + /** + * Is the charging station open to the general public? + */ + public_charger: z + .enum([ + '*adapter required for use', + 'City Hall Charging', + 'NYC DC Fast Public Charging', + 'Solar Carport Charging', + 'Yes', + ]) + .optional(), + + /** + * Is there a fee to use the charger at the station for City drivers? + */ + fee_for_city_drivers: z.string().optional(), + + /** + * Latitude of station's address. + */ + latitude: z.coerce.number().optional(), + + /** + * Longitude of station's address. + */ + longitude: z.coerce.number().optional(), + + /** + * Community Districts are geographic areas created under the City Charter + * that are used for the delivery of municipal services, to facilitate + * community engagement, and as a common unit of analysis. The City is + * divided into 59 community districts. Each Community District has a + * Community Board. + */ + community_district: z.coerce.number().optional(), + + /** + * The City Council is the lawmaking body of NYC, on equal footing with + * the mayor in terms of governing power. Besides legislating, the Council + * has sole approval power over the city budget, and is the final + * decision-maker in land use matters. There are 51 City Council members + * in total, each representing a unique geographical area, called a + * Council District. + */ + council_district: z.coerce.number().optional(), + + /** + * Geographic area defined by the U.S. Census Bureau for the various + * decennial censuses. Census tracts for a particular census year are + * numbered uniquely within borough. Please note that as part of the + * geocoding process, leading and trailing zeros are dropped. + */ + census_tract: z.coerce.number().optional(), + + /** + * A unique 7-digit number assigned to every known building by the + * Department of City Planning (DCP), the first digit of which is the + * borough code. BINs allow city agencies to process and match + * building-related data easily and in a consistent manner. + */ + bin: z.coerce.number().optional(), + + /** + * A combination of three numeric codes -- a 1-digit borough number, a + * block number (up to 5 digits) and a lot number (up to 4 digits) -- + * designated and modified by the Department of Finance (DOF). BBLs are + * used by various city agencies to identify real estate for taxes, + * zoning, construction, and other purposes. + */ + bbl: z.coerce.number().optional(), + + /** + * NTAs are small area boundaries, created by the Department of City + * Planning (DCP) to aggregate population projections in a small area. + * Each NTA approximates a minimum population of 15,000. While NTAs were + * initially created to support PlaNYC, the thirty-year (2000-2030) + * sustainability plan for NYC, NTAs are now also being used to present + * data from the Decennial Census and American Community Survey. NTA + * boundaries and their associated names do not definitively represent + * neighborhood boundaries. + */ + nta: z.string().optional(), + + // Unknown Keys - keys that are not in the docs but are in the data + public_charger_: z.string().optional(), + }) + .strict(); + +export type EVChargingStation = z.infer; + +export const EVChargingStationsSchema = z.array(EVChargingStationSchema); diff --git a/types/outages.ts b/types/outages.ts new file mode 100644 index 0000000..739b805 --- /dev/null +++ b/types/outages.ts @@ -0,0 +1,248 @@ +import { z } from 'zod'; + +export const OutageSchema = z + .object({ + /** + * Unique identifier of a Service Request (SR) in the open data set + */ + unique_key: z.string(), + + /** + * Date SR was created + */ + created_date: z.coerce.date(), + + /** + * Date SR was closed by responding agency + */ + closed_date: z.coerce.date().optional(), + + /** + * Acronym of responding City Government Agency + */ + agency: z.enum(['HPD']), + + /** + * Full Agency name of responding City Government Agency + */ + agency_name: z.enum([ + 'Department of Housing Preservation and Development', + 'Division of Alternative Management', + ]), + + /** + * This is the first level of a hierarchy identifying the topic of the + * incident or condition. Complaint Type may have a corresponding + * Descriptor (below) or may stand alone. + */ + complaint_type: z.enum(['ELECTRIC', 'Electric']), + + /** + * This is associated to the Complaint Type, and provides further detail on + * the incident or condition. Descriptor values are dependent on the + * Complaint Type, and are not always required in SR. + */ + descriptor: z.enum(['POWER OUTAGE', 'Power Outage']), + + /** + * Describes the type of location used in the address information + */ + location_type: z.enum([ + 'Apartment', + 'Building-Wide', + 'RESIDENTIAL BUILDING', + ]), + + /** + * Incident location zip code, provided by geo validation. + */ + incident_zip: z.coerce.number().optional(), + + /** + * House number of incident address provided by submitter. + */ + incident_address: z.string(), + + /** + * Street name of incident address provided by the submitter + */ + street_name: z.string(), + + /** + * First Cross street based on the geo validated incident location + */ + cross_street_1: z.string().optional(), + + /** + * Second Cross Street based on the geo validated incident location + */ + cross_street_2: z.string().optional(), + + /** + * First intersecting street based on geo validated incident location + */ + intersection_street_1: z.string().optional(), + + /** + * Second intersecting street based on geo validated incident location + */ + intersection_street_2: z.string().optional(), + + /** + * Type of incident location information available. + */ + address_type: z.enum(['ADDRESS']).optional(), + + /** + * City of the incident location provided by geovalidation. + */ + city: z.string().optional(), + + /** + * If the incident location is identified as a Landmark the name of the + * landmark will display here + */ + landmark: z.string().optional(), + + /** + * If available, this field describes the type of city facility associated + * to the SR + */ + facility_type: z.string().optional(), + + /** + * Status of SR submitted + */ + status: z.enum(['Closed', 'In Progress', 'Open']), + + /** + * Date when responding agency is expected to update the SR. This is based + * on the Complaint Type and internal Service Level Agreements (SLAs). + */ + due_date: z.coerce.date().optional(), + + /** + * Date when responding agency last updated the SR. + */ + resolution_action_updated_date: z.coerce.date().optional(), + + /** + * Provided by geovalidation. + */ + community_board: z.string(), + + /** + * Provided by the submitter and confirmed by geovalidation. + */ + borough: z.enum([ + 'BRONX', + 'BROOKLYN', + 'MANHATTAN', + 'QUEENS', + 'STATEN ISLAND', + 'Unspecified', + ]), + + /** + * Geo validated, X coordinate of the incident location. + */ + x_coordinate_state_plane: z.coerce.number().optional(), + + /** + * Geo validated, Y coordinate of the incident location. + */ + y_coordinate_state_plane: z.coerce.number().optional(), + + /** + * If the incident location is a Parks Dept facility, the Name of the + * facility will appear here + */ + park_facility_name: z.string(), + + /** + * The borough of incident if it is a Parks Dept facility + */ + park_borough: z.enum([ + 'BRONX', + 'BROOKLYN', + 'MANHATTAN', + 'QUEENS', + 'STATEN ISLAND', + 'Unspecified', + ]), + + /** + * If the incident is a taxi, this field describes the type of TLC + * vehicle. + */ + vehicle_type: z.string().optional(), + + /** + * If the incident is identified as a taxi, this field will display the + * borough of the taxi company. + */ + taxi_company_borough: z.string().optional(), + + /** + * If the incident is identified as a taxi, this field displays the taxi + * pick up location + */ + taxi_pick_up_location: z.string().optional(), + + /** + * If the incident is identified as a Bridge/Highway, the name will be + * displayed here. + */ + bridge_highway_name: z.string().optional(), + + /** + * If the incident is identified as a Bridge/Highway, the direction where + * the issue took place would be displayed here. + */ + bridge_highway_direction: z.string().optional(), + + /** + * If the incident location was Bridge/Highway this column differentiates + * if the issue was on the Road or the Ramp. + */ + road_ramp: z.string().optional(), + + /** + * Additional information on the section of the Bridge/Highway were the + * incident took place. + */ + bridge_highway_segment: z.string().optional(), + + /** + * Geo based Lat of the incident location + */ + latitude: z.coerce.number().optional(), + + /** + * Geo based Long of the incident location + */ + longitude: z.coerce.number().optional(), + + /** + * Combination of the geo based lat & long of the incident location + */ + location: z + .object({ + coordinates: z.tuple([z.number(), z.number()]), + type: z.literal('Point'), + }) + .strict() + .optional(), + + // Unknown Keys - keys that are not in the docs but are in the data + ':@computed_region_sbqj_enih': z.coerce.number().optional(), + ':@computed_region_efsh_h5xi': z.coerce.number().optional(), + ':@computed_region_92fq_4b7q': z.coerce.number().optional(), + ':@computed_region_yeji_bk3q': z.coerce.number().optional(), + ':@computed_region_f5dn_yrer': z.coerce.number().optional(), + }) + .strict(); + +export type Outage = z.infer; + +export const OutagesSchema = z.array(OutageSchema); diff --git a/types/projects.ts b/types/projects.ts new file mode 100644 index 0000000..601261f --- /dev/null +++ b/types/projects.ts @@ -0,0 +1,357 @@ +import { z } from 'zod'; + +export const ProjectSchema = z + .object({ + /** The date the dataset was refreshed */ + data_through_date: z.coerce.date(), + + /** + * Either Maintenance, Non-Tier 1, Tier 1, Tier 2, Tier 4, OREC, or N/A. + * Maintenance represents existing baseline resources. Non-Tier 1 are + * facilities with a commercial operation date before 1/1/2015, and Tier 1 + * are facilities with a commercial operation date after 1/1/2015. Tier 2 + * facilities are from wind and non-state-owned run-of-river hydroelectric + * generating facilities located within New York State that entered + * commercial operation prior to January 1, 2015. OREC represents projects + * in the offshore wind program. Tier 4 represents renewable energy and + * transmission projects. + */ + eligibility: z + .enum([ + 'Maintenance', + 'Non-Tier 1', + 'OREC', + 'Tier 1', + 'Tier 2', + 'Tier 4', + 'N/A', + ]) + .optional(), + + /** + * Name of project. Project names with an asterisk have the same project + * name as projects awarded under different solicitations + */ + project_name: z.string(), + + /** + * Request for Proposal (RFP) number, or Maintenance Agreement number under + * Renewable Portfolio Standard (RPS) or Tier 2 Maintenance under the + * Renewable Energy Standard (RES) + */ + solicitation_name: z.string(), + + /** + * Certain solicitations provided for an optional or mandatory adjustment to + * the Index REC Strike Price or Fixed REC Price based on changes in + * certain commodity price indices as described in each RFP. Proposers + * awarded under these solicitations whose proposals were subject to this + * adjustment may have their Index REC Strike Price or Fixed REC Price + * adjusted consistent with the methodology described in each applicable RFP. + */ + inflation_adjustment: z.enum(['Yes']).optional(), + + /** + * The Fixed REC bid price of Tier 1 and Non-Tier 1 awarded projects in US + * dollars. Blank cells represent data that was not required or is not + * currently available. + */ + fixed_rec_price: z.coerce.number().optional(), + + /** + * The Index OREC bid price or the Index REC Conversion Offer Price of + * Tier 1 or Off-shore wind awarded projects in US dollars. Blank cells + * represent data that was not required or is not currently available. + */ + index_orec_strike_price: z.coerce.number().optional(), + + /** + * Indicates if a Non-Tier 1 or Tier 1 Project claimed Incremental Economic + * Benefits as part of its Bid Proposal, and these claims were accepted by + * NYSERDA as part of its award. Blank cells represent data that was not + * required or is not currently available. For more information, please + * refer to each specific RFP's Section on Incremental Economic Benefits to + * New York State. + */ + incremental_economic_benefits_claimed: z.enum(['Yes']).optional(), + + /** + * For Non-Tier 1 or Tier I Projects, indicates the verification status of + * the Incremental Economic Benefits included in the projects Agreement. + * Incremental Economic Benefits are verified following three years of + * operation and must meet a threshold of 85% of claim accepted by NYSERDA. + * Yes - indicates verification complete and project met threshold. No – + * verification complete and project did not meet threshold. Blank cells + * represent – verification not due yet/ not yet complete; no economic + * benefit claim; or project cancelled/terminated. + */ + project_met_economic_benefits_threshold: z.enum(['Yes']).optional(), + + /** + * The type of renewable energy technology for the project currently under + * contract or installed; either Land Based Wind, Offshore Wind, + * Hydroelectric, Biomass, Biogas-LFG, Biogas-ADG, Fuel Cell, Solar, + * Geothermal, Maintenance Biomass, or Maintenance Hydroelectric. + */ + renewable_technology: z + .enum([ + 'Biogas - ADG', + 'Biogas - LFG', + 'Biomass', + 'Fuel Cell', + 'Hydroelectric', + 'Land Based Wind', + 'Land-based Wind', + 'Maintenance Biomass', + 'Maintenance Hydroelectric', + 'Offshore Wind', + 'Solar', + 'Wind/Solar', + ]) + .optional(), + + /** + * Indicates the type of eligible generation, either Existing or New + * Generation. Blank cells represent data that was not required or is not + * currently available. + */ + generation_type: z.enum(['Existing', 'New']).optional(), + + /** + * Vintage Generation Facility (VGS). VGFs are projects that are either + * upgraded, returned to service, relocated, or repowered, and must meet + * the specified requirements + */ + type_of_existing: z + .enum(['Repower', 'Return to Service', 'Upgrade']) + .optional(), + + /** + * Name of the Seller in NYSERDA's agreement. A counterparty is the other + * party to a NYSERDA agreement. Blank cells represent data that was not + * required or is not currently available. + */ + counterparty: z.string().optional(), + + /** + * Name of Developer responsible for developing the project. Blank cells + * represent data that was not required or is not currently available. + */ + developer_name: z.string().optional(), + + /** + * Rating of the electric power to be delivered from an energy storage + * project in alternating current (AC) power. Blank cells represent data + * that was not required or is not currently available. + */ + energy_storage_power_capacity_mwac: z.coerce.number().optional(), + + /** + * Usable installed energy storage capacity measured in alternating current + * (AC) power. It is equal to the total capacity measured during a complete + * discharge from a 100% usable state of charge, performed in accordance + * with the storage manufacturer's specifications, on the commercial + * operation date. Blank cells represent data that was not required or is + * not currently available. This category does not include pumped storage. + */ + energy_storage_energy_capacity_mwh: z.coerce.number().optional(), + + /** + * Indicates the New York Control Area Load Zone into which the Bid + * Facility will interconnect or deliver. Blank cells represent data that + * was not required or is not currently available. + */ + nyiso_zone: z.string().optional(), + + /** + * Indicates the NYISO Generator Point Identifier. A Point Identifier is a + * resource-specific numerical identifier used by the NYISO's software + * systems to identify Generators and other Suppliers and only applies to + * Operating Projects. Blank cells represent data that was not required or + * is not currently available. + */ + ptid: z.string().optional(), + + /** + * For the NYISO Interconnection Queue, indicates the number on the list of + * transmission and generation projects seeking to join the grid. + * Additional information is located at: + * https://www.nyiso.com/interconnections. Tier 4 projects include both + * Resources and Transmission numbers. + */ + interconnection_queue_number: z.string().optional(), + + /** + * The applicable federal, state, or local permitting requirements that the + * Bid Facility is subject to. Blank cells represent data that was not + * required or is not currently available. + */ + permit_process: z.string().optional(), + + /** + * For Offshore Wind and Tier 4 projects, the Federal Permitting Mechanism + * related to Environmental Review and Permitting. For Tier 1 projects, the + * Name of Article 10 or Article VIII Case Number. Blank cells represent + * data that were not required, applicable or are not currently available + */ + regulatory_permitting: z + .object({ + url: z.string(), + }) + .strict() + .optional(), + + /** Name of Article VII pertaining to Offshore Wind Project or Tier 4 Project. */ + article_vii: z + .object({ + url: z.string(), + }) + .strict() + .optional(), + + /** + * ZIP code of project. Blank cells represent data that was not required or + * is not currently available. + */ + zip_code: z.string().optional(), + + /** + * Name of US county or Canadian Province for project. Blank cells + * represent data that was not required or is not currently available. + */ + county_province: z.string().optional(), + + /** + * Name of US state or Canadian Province for project. Blank cells represent + * data that was not required or is not currently available. + */ + state_province: z.string().optional(), + + /** + * Regional Economic Development Council of project. In 2011, 10 Regional + * Councils were established and charged with developing long-term + * strategic plans for economic growth in their regions. Blank cells + * indicate the project is not located in New York State. + */ + redc: z.string().optional(), + + /** + * The phase/status of which the project is in as of the Data Through + * Date; either Cancelled, Completed, Under Development, or Operational. A + * status of Cancelled means that NYSERDA's award or contract with the + * counterparty was cancelled or terminated. A status of Completed means + * the project has fulfilled their contractual obligation to NYSERDA. A + * status of Under Development means that the project has not yet entered + * operation. A status of Operational means that the project has entered + * operation. + */ + project_status: z.enum([ + 'Cancelled', + 'Completed', + 'Under Development', + 'Operational', + ]), + + /** + * Date declared as the Commercial Operation date of facility by NYISO or + * local utility. Blank cells represent data that was not required or is + * not currently available. + */ + year_of_commercial_operation: z.string().optional(), + + /** + * Date NYSERDA's payments started or are expected to start. For projects + * Under Development, the dates are reflective of the current status of + * NYSERDA agreements and are subject to change based on project + * development progress. Blank cells represent data that was not required + * or is not currently available. + */ + year_of_delivery_start_date: z.string().optional(), + + /** Number of years of performance under the NYSERDA Agreement. */ + contract_duration: z.coerce.number().optional(), + + /** + * For a new facility, the New Renewable Capacity equals the nameplate + * capacity of the installed equipment. For a repowered facility, End of + * Useful Life Bid Capacity is reported. For an upgraded facility, only the + * renewable capacity resulting from the upgrade of a facility is provided. + * For a single facility that was awarded multiple contracts, each for a + * percentage of the facility's output under multiple RFPs, the total New + * Renewable Capacity for the facility is listed once to avoid + * over-counting. Therefore, the same facility appearing in multiple + * instances may show a New Renewable Capacity of zero. Blank cells + * represent projects with expired contracts with NYSERDA. + */ + new_renewable_capacity_mw: z.coerce.number().optional(), + + /** + * The NYSERDA-contracted portion of the New Renewable Capacity in + * Megawatts. Blank cells represent data that were not applicable, + * required or are not currently available. + */ + bid_capacity_mw: z.coerce.number().optional(), + + /** + * The annual production of the project in Megawatt hours that NYSERDA and + * the Counterparty agree on constitutes performance in an Agreement. The + * Bid Quantity may not constitute the entire output of a project. Blank + * cells represent data that was not required or is not currently + * available. + */ + bid_quantity_mwh: z.coerce.number().optional(), + + /** + * The maximum annual contractual obligation of the project in Megawatt + * hours. Blank cells represent data that was not required or is not + * currently available. + */ + max_annual_contract_quantity: z.coerce.number().optional(), + + /** + * An amount of electrical energy (in MWh), such that the estimated + * probability in any given year that generation from the Selected Project + * delivered to the Delivery Point would exceed that amount is 10 percent. + * Applicable to Offshore Wind projects only. Blank cells represent data + * that was not required or is not currently available. + */ + p10_annual_orec_exceedance: z.coerce.number().optional(), + + /** + * A NYSERDA calculation based on a 10.5 meter/second average Hub Height + * (m/s avgHH) wind speed. Weibull parameters of A = 11.3 and K = 2.2 were + * used to create an annual wind frequency distribution table. The + * resulting frequency distribution was then applied in the NREL 15 MW + * turbine power curve assuming 22% losses. Blank cells represent data + * there were not required or is not currently available. + */ + p50_generation_calculated_by_nyserda_mwh_yr: z.coerce.number().optional(), + + /** + * Capacity of a project's new HVDC transmission line. Applicable to Tier 4 + * projects only. Blank cells represent data that was not required or is + * not currently available. + */ + transmission_capacity_hvdc: z.coerce.number().optional(), + + /** + * Open Data/Socrata-generated geocoding information based on supplied + * address components. + */ + georeference: z + .object({ + coordinates: z.tuple([z.number(), z.number()]), + type: z.literal('Point'), + }) + .strict() + .optional(), + + // Unknown Keys - keys that are not in the docs but are in the data + transmission_capacity_hvdc_: z.coerce.number().optional(), + p50_generation_calculated_by_nyserda_mwh_yr_: z.coerce.number().optional(), + }) + .strict(); + +export type Project = z.infer; + +export const ProjectsSchema = z.array(ProjectSchema); diff --git a/types/ratSightings.ts b/types/ratSightings.ts new file mode 100644 index 0000000..14dcc29 --- /dev/null +++ b/types/ratSightings.ts @@ -0,0 +1,270 @@ +import { z } from 'zod'; + +export const RatSightingSchema = z + .object({ + /** + * Unique identifier of a Service Request (SR) in the open data set + */ + unique_key: z.coerce.number(), + + /** + * Date SR was created + */ + created_date: z.coerce.date(), + + /** + * Date SR was closed by responding agency + */ + closed_date: z.coerce.date().optional(), + + /** + * Acronym of responding City Government Agency + */ + agency: z.enum(['DOHMH']), + + /** + * Full Agency name of responding City Government Agency + */ + agency_name: z.enum(['Department of Health and Mental Hygiene']), + + /** + * This is the first level of a hierarchy identifying the topic of the + * incident or condition. Complaint Type may have a corresponding + * Descriptor (below) or may stand alone. + */ + complaint_type: z.enum(['Rodent']), + + /** + * This is associated to the Complaint Type, and provides further detail + * on the incident or condition. Descriptor values are dependent on the + * Complaint Type, and are not always required in SR. + */ + descriptor: z.enum(['Rat Sighting']), + + /** + * Describes the type of location used in the address information + */ + location_type: z.string().optional(), + + /** + * Incident location zip code, provided by geo validation. + */ + //? Handle extremely rare edge case, where the string 'N/A' is returned + incident_zip: z.preprocess( + (val) => (val === 'N/A' ? undefined : val), + z.coerce.number().optional() + ), + + /** + * House number of incident address provided by submitter. + */ + incident_address: z.string().optional(), + + /** + * Street name of incident address provided by the submitter + */ + street_name: z.string().optional(), + + /** + * First Cross street based on the geo validated incident location + */ + cross_street_1: z.string().optional(), + + /** + * Second Cross Street based on the geo validated incident location + */ + cross_street_2: z.string().optional(), + + /** + * First intersecting street based on geo validated incident location + */ + intersection_street_1: z.string().optional(), + + /** + * Second intersecting street based on geo validated incident location + */ + intersection_street_2: z.string().optional(), + + /** + * Type of incident location information available. + */ + address_type: z + .enum([ + 'ADDRESS', + 'BLOCKFACE', + 'INTERSECTION', + 'LATLONG', + 'PLACENAME', + 'UNRECOGNIZED', + ]) + .optional(), + + /** + * City of the incident location provided by geovalidation. + */ + city: z.string().optional(), + + /** + * If the incident location is identified as a Landmark the name of the + * landmark will display here + */ + landmark: z.string().optional(), + + /** + * If available, this field describes the type of city facility + * associated to the SR + */ + facility_type: z.string().optional(), + + /** + * Status of SR submitted + */ + status: z.enum([ + 'Assigned', + 'Closed', + 'Draft', + 'In Progress', + 'Open', + 'Pending', + 'Unspecified', + ]), + + /** + * Date when responding agency is expected to update the SR. This is + * based on the Complaint Type and internal Service Level Agreements + * (SLAs). + */ + due_date: z.coerce.date().optional(), + + /** + * Date when responding agency last updated the SR. + */ + resolution_action_updated_date: z.coerce.date().optional(), + + /** + * Provided by geovalidation. + */ + community_board: z.string().optional(), + + /** + * Provided by the submitter and confirmed by geovalidation. + */ + borough: z + .enum([ + 'BRONX', + 'BROOKLYN', + 'MANHATTAN', + 'QUEENS', + 'STATEN ISLAND', + 'Unspecified', + ]) + .optional(), + + /** + * Geo validated, X coordinate of the incident location. + */ + x_coordinate_state_plane: z.coerce.number().optional(), + + /** + * Geo validated, Y coordinate of the incident location. + */ + y_coordinate_state_plane: z.coerce.number().optional(), + + /** + * If the incident location is a Parks Dept facility, the Name of the + * facility will appear here + */ + park_facility_name: z.string(), + + /** + * The borough of incident if it is a Parks Dept facility + */ + park_borough: z + .enum([ + 'BRONX', + 'BROOKLYN', + 'MANHATTAN', + 'QUEENS', + 'STATEN ISLAND', + 'Unspecified', + '', + ]) + .optional(), + + /** + * If the incident is a taxi, this field describes the type of TLC + * vehicle. + */ + vehicle_type: z.string().optional(), + + /** + * If the incident is identified as a taxi, this field will display the + * borough of the taxi company. + */ + taxi_company_borough: z.string().optional(), + + /** + * If the incident is identified as a taxi, this field displays the taxi + * pick up location + */ + taxi_pick_up_location: z.string().optional(), + + /** + * If the incident is identified as a Bridge/Highway, the name will be + * displayed here. + */ + bridge_highway_name: z.string().optional(), + + /** + * If the incident is identified as a Bridge/Highway, the direction where + * the issue took place would be displayed here. + */ + bridge_highway_direction: z.string().optional(), + + /** + * If the incident location was Bridge/Highway this column differentiates + * if the issue was on the Road or the Ramp. + */ + road_ramp: z.string().optional(), + + /** + * Additional information on the section of the Bridge/Highway were the + * incident took place. + */ + bridge_highway_segment: z.string().optional(), + + /** + * Geo based Lat of the incident location + */ + latitude: z.coerce.number().optional(), + + /** + * Geo based Long of the incident location + */ + longitude: z.coerce.number().optional(), + + /** + * Combination of the geo based lat & long of the incident location + */ + location: z + .object({ + coordinates: z.tuple([z.number(), z.number()]), + type: z.literal('Point'), + }) + .strict() + .optional(), + + // Unknown Keys - keys that are not in the docs but are in the data + x_coordinate_state_plane_: z.coerce.number().optional(), + y_coordinate_state_plane_: z.coerce.number().optional(), + ':@computed_region_efsh_h5xi': z.coerce.number().optional(), + ':@computed_region_f5dn_yrer': z.coerce.number().optional(), + ':@computed_region_yeji_bk3q': z.coerce.number().optional(), + ':@computed_region_92fq_4b7q': z.coerce.number().optional(), + ':@computed_region_sbqj_enih': z.coerce.number().optional(), + }) + .strict(); + +export type RatSighting = z.infer; + +export const RatSightingsSchema = z.array(RatSightingSchema);