Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -38,6 +43,7 @@ export default function RootLayout({
<head>
<link rel='icon' href='/images/icon-green.png' />
<link rel='shortcut icon' href='/images/icon-green.png' />
{/* TODO: Remove this once new maps page is complete */}
<link
rel='stylesheet'
href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css'
Expand Down
158 changes: 158 additions & 0 deletions app/map/v2/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
'use server';

import { z } from 'zod';

import { EVChargingStationsSchema } from '@/types/evChargingStations';
import { OutagesSchema } from '@/types/outages';
import { ProjectsSchema } from '@/types/projects';
import { RatSightingsSchema } from '@/types/ratSightings';

const SOCRATA_APP_TOKEN = process.env.SOCRATA_APP_TOKEN!;

if (!SOCRATA_APP_TOKEN) {
throw new Error('SOCRATA_APP_TOKEN is not set');
}

type SocrataQueryParams = {
/**
* The set of columns to be returned, similar to a SELECT in SQL
*
* @default All columns, equivalent to $select=*
*/
select?: string;
/**
* Filters the rows to be returned, similar to a WHERE in SQL
*
* @default No filter
*/
where?: string;
/**
* Column to order results on, similar to a ORDER BY in SQL
*
* @default Unspecified order
*/
order?: string;
/**
* Column to group results on, similar to a GROUP BY in SQL
*
* @default No grouping
*/
group?: string;
/**
* Filters the rows that result from an aggregation, similar to HAVING
*
* @default No filter
*/
having?: string;
/**
* Maximum number of results to return
*
* @default 1000
*/
limit?: number;
/**
* Offset count into the results to start at, used for paging
*
* @default 0
**/
offset?: number;
/**
* Performs a full text search for a value.
*
* @default No search
*/
q?: string;
};

async function fetchSocrataData<T>({
endpoint,
schema,
params,
}: {
endpoint: string;
schema: z.ZodType<T>;
params: SocrataQueryParams;
}): Promise<T> {
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);
}
}
212 changes: 212 additions & 0 deletions app/map/v2/page.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map>(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 (
<>
<main ref={mapContainerRef} className='h-[100vh] w-[100vw]' />
<SideBar isOpen={isSidebarOpen} onClose={toggleSidebar} />
</>
);
}
Loading