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
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
Add a readme for your dashboard here. Include content overview, data citations, and any relevant technical details.
## Content overview

**Topic**

The impact of SEPTA's August-September 2025 service cuts.

**Intended users**

SEPTA bus riders and potential SEPTA bus riders.

**What users could do with this information**

- Find which bus routes were cut, shortened, or reduced in their PA Senate district (important for advocating for funding at the legislature)
- Find the overall level of service cuts in their neighborhood.
- Find which state legislators represent them and their contact information.

**Data needed to enable the above**

- SEPTA GTFS routes and stops data from before and after the cuts.
- Neighborhood boundaries.
- Political district boundaries.

## Data

The underlying data for the dashboard comes from SEPTA's GTFS archive; I compare the [last GTFS update](https://github.com/septadev/GTFS/releases/tag/v202508242) prior to the cuts with the first update describing service after the [restoration of service](https://github.com/septadev/GTFS/releases/tag/v202509141). (I use the post-cut period schedule so that seasonal routes, like school-focused routes that were affected by the cuts, can be shown appropriately.)

I then use an R script (under the `r` directory) to read in the GTFS data (via the `tidytransit` package), add relevant attributes, and export route segments as a geojson file.
80 changes: 80 additions & 0 deletions css/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
html {
font-size: 24px;
font-family: 'Gill Sans', sans-serif;
}

body {
padding: 0;
margin: 0;
}

.content {
display: flex;
flex-direction: row;
min-height: 100vh;
}

.map-container {
position: relative;
min-width: 0;
background-color: silver;
flex: 1 1 70%;
}

#map {
position: absolute;
inset: 0;
}

.legend {
line-height: 20px;
color: #555;
padding: 6px 8px;
background: white;
background: rgba(255,255,255,0.8);
box-shadow: 0 0 15px rgba(0,0,0,0.2);
border-radius: 5px;
}

.legend i {
width: 18px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.9;
}

.sidebar-container {
position: relative;
background-color: #e9c46a;
display: flex;
flex-direction: column;
padding: 16px;
flex: 0 0 30%;
}

.location-search-inputs {
display: flex;
flex-direction: row;
}

#suggestions {
list-style: none;
padding: 0;
}

.citations {
flex: 1 0 auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
font-size: 0.6em;
}







23 changes: 23 additions & 0 deletions data/districts.json

Large diffs are not rendered by default.

1,234 changes: 1,234 additions & 0 deletions data/overall_network.json

Large diffs are not rendered by default.

57 changes: 57 additions & 0 deletions data/raw/geographic_areas/Pennsylvania_Senate_Districts.geojson

Large diffs are not rendered by default.

Binary file added data/raw/septa_cut/google_bus.zip
Binary file not shown.
Binary file added data/raw/septa_normal/google_bus.zip
Binary file not shown.
64 changes: 64 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>

<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">

<!-- Load leaflet -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>

<script type="module" src="js/main.js" defer></script>
<link rel="stylesheet" href="css/styles.css">

<title>SEPTA cuts in your area</title>
</head>

<body>
<main>
<div class="content">
<div class="map-container">
<div id="map"></div>
</div>

<div class="sidebar-container">

<div class="info-container">
<section class="directions">
<h2>SEPTA cuts in your area</h2>
<p>This dashboard allows you to see the impacts of the SEPTA bus service cuts in August 2025, for your own area.</p>
<p>Enter your address in the search bar below to find out how the SEPTA cuts impacted your area.</p>
</section>
</div>

<div class="location-search-container">
<div class="location-search-inputs">
<input type="text" id="address-input" name="address-input" placeholder="Enter your address">
<button id="geolocate-button">Or use your location</button>
</div>
<ul id="suggestions" class="suggestions"></ul>
<div id="district-num" class="district-num"></div>
</div>

<div class="results-container">
<section id="results" class="results">
<h3>Impacts in the overall SEPTA region</h3>
<p>Across the system, <b>32</b> bus routes (19% of routes), were completely eliminated and <b>16</b> routes (10% of routes) were shortened.</p>
</section>
</div>

<div class="citations">
<p>Datasource: SEPTA <a href="https://github.com/septadev/GTFS/releases">GTFS feed</a>, comparing 8/25 release with 9/12 release.</p>
</div>

</div>
</div>

</main>
</body>
</html>
120 changes: 120 additions & 0 deletions js/location.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import _ from 'https://cdn.jsdelivr.net/npm/lodash@4.17.21/+esm';
import * as turf from 'https://cdn.jsdelivr.net/npm/@turf/turf@7.1.0/+esm';

import { getGeojsonCollection } from './utils.js';

const geolocateButton = document.querySelector('#geolocate-button');
const addressInput = document.querySelector('#address-input');
const addressSuggestions = document.querySelector('#suggestions');

const districts = await getGeojsonCollection('districts.json');

/**
* Geolocate based on browser's geolocation API.
* @param {EventTarget} events The event bus used to communicate between app components.
*/
function geolocate(events) {
geolocateButton.addEventListener('click', () => {
navigator.geolocation.getCurrentPosition(
(position) => {
const lat = position.coords.latitude;
const lng = position.coords.longitude;
console.log(`Location acquired: Latitude: ${lat}, Longitude: ${lng}`);

const evt = new CustomEvent('userLocationAcquired', {
detail: { lat, lng },
});
events.dispatchEvent(evt);
},
(error) => {
console.error('Error obtaining location:', error);
alert('Unable to retrieve your location. Did you allow location access?');
}
);
});
}

/**
* Geocodes address input and presents results.
* @param {EventTarget} events The event bus used to communicate between app components.
*/
function suggestAddress(events) {
const apiUrlBase = 'https://geocoding.geo.census.gov/geocoder/locations/address?city=Philadelphia&state=PA&benchmark=4&format=json&street=';

const inputHandler = async () => {
console.log('Input changed:', addressInput.value);

const response = await fetch('https://corsproxy.io/?url=' + apiUrlBase + encodeURIComponent(addressInput.value));
const data = await response.json();
console.log('Autocomplete suggestions:', data);

addressSuggestions.innerHTML = '';

const suggestions = data.result.addressMatches;
if (!suggestions || suggestions.length === 0) {
const noResultsItem = document.createElement('li');
noResultsItem.textContent = 'No matches found.';
addressSuggestions.appendChild(noResultsItem);
}

for (const match of suggestions) {
const listItem = document.createElement('li');
listItem.innerHTML = `Select your address:<br><button>${match.matchedAddress}</button>`;
listItem.querySelector('button').addEventListener('click', () => {
addressInput.value = match.matchedAddress;
addressSuggestions.innerHTML = '';

const lat = match.coordinates.y;
const lng = match.coordinates.x;
const evt = new CustomEvent('userLocationAcquired', {
detail: { lat, lng },
});
events.dispatchEvent(evt);
console.log(`Location acquired: Latitude: ${lat}, Longitude: ${lng}`);
});
addressSuggestions.appendChild(listItem);
}
};

const debouncedInputHandler = _.debounce(inputHandler, 500);

addressInput.addEventListener('input', debouncedInputHandler);
}

/**
* Based on the user's location, get correct PA Sen district number and print result.
* @param {EventTarget} events The event bus used to communicate between app components.
*/
function getDistrict(events) {
events.addEventListener('userLocationAcquired', (event) => {
const { lat, lng } = event.detail;
const pt = turf.point([lng, lat]);
// Get the correct district number based on user's location
const district = findIntersectingPolygon(pt, districts);
console.log('Intersecting district:', district);
// Dispatch the district data to event bus
const evt = new CustomEvent('districtData', {
detail: district,
});
events.dispatchEvent(evt);
console.log('District data dispatched:', district);
}
)
}

/**
* Given a point and a collection of polygons, find the polygon that contains the point.
* @param {Object} point A Turf point object.
* @param {Object} polygons A Turf FeatureCollection of polygons.
*/
function findIntersectingPolygon(point, polygons) {
for (var i = 0; i < polygons.features.length; i++) {
var polygon = polygons.features[i];
if (turf.booleanPointInPolygon(point, polygon)) {
return polygon; // Returns the specific polygon feature that contains the point
}
}
return null; // No containing polygon found
}

export { geolocate, suggestAddress, getDistrict };
53 changes: 53 additions & 0 deletions js/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { getGeojsonCollection } from './utils.js';
import { initMap, overlayMask } from './map.js';
import { geolocate, suggestAddress, getDistrict } from './location.js';

// Events in event bus:
// - userLocationAcquired: { detail: { lat, lng } }
// - districtData: { detail: { features of district } }
const events = new EventTarget();

const mapElement = document.querySelector('#map');
const septaNetwork = await getGeojsonCollection('overall_network.json');

const districtNum = document.querySelector('#district-num');
const results = document.querySelector('#results');

function displayDistrictData(events) {
events.addEventListener('districtData', (event) => {
const district = event.detail;
// Get attributes of the district
const districtNumber = district.properties.leg_district_no;
const routes_eliminated = district.properties.routes_eliminated;
const routes_shortened = district.properties.routes_shortened;
const percent_eliminated = district.properties.percent_eliminated;
const percent_shortened = district.properties.percent_shortened;
const s_firstname = district.properties.s_firstname;
const s_lastname = district.properties.s_lastname;
// Display the district number, or error message if district number is not found
if (!district) {
districtNum.textContent = 'Please enter an address within Philadelphia.';
}
else {
districtNum.textContent = `Your PA Senate district is: ${districtNumber}`;
}
// Display custom message in results section
results.innerHTML = `
<h3>Impacts in your area</h3>
<p>In Senate District ${districtNumber}, <b>${routes_eliminated}</b> bus routes (${percent_eliminated} of routes), were completely eliminated and <b>${routes_shortened}</b> routes (${percent_shortened} of routes) were shortened.</p>
<p>While these cuts were reversed in September 2025, without dedicated funding, these cuts may occur again. To advocate for sustainable transit funding, please consider contacting your state senator, ${s_firstname} ${s_lastname}, <a href="https://www.palegis.us/senate/members">here</a>.</p>
`;
});
}

initMap(mapElement, septaNetwork);
geolocate(events);
suggestAddress(events);
getDistrict(events);
displayDistrictData(events);
overlayMask(events);





Loading