Skip to content
Merged
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: 8 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
* Get shuttle bus routes... schedules (make them updatable)
* Get building hours (make them updatable, and schedulable (e.g. summer hours vrs not))
* Get detailed elevator information
* stuff like lactation rooms takes you to the building, but does not get you to the room
* pathways inside buildings
* supplementary photographs...
* map non-accessibility-friendly entrances
* brookline campus has no accessible entrances mapped
13 changes: 13 additions & 0 deletions help.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ <h2 id="contents-title">Contents</h2>
<li><a href="#audio">Spoken directions</a></li>
<li><a href="#facing">Facing the right way, and clock directions</a></li>
<li><a href="#where">Where am I?</a></li>
<li><a href="#between-campuses">Between campuses, and from off campus</a></li>
<li><a href="#blue-light">Blue light: the nearest emergency phone</a></li>
<li><a href="#share">Sharing a trip</a></li>
<li><a href="#report">Reporting a problem</a></li>
Expand Down Expand Up @@ -172,6 +173,18 @@ <h2 id="where-title">Where am I?</h2>
<p>The summary and the two nearest things are spoken; the full list is shown in the panel. These are straight-line distances, not walking distances.</p>
</section>

<section id="between-campuses" aria-labelledby="between-campuses-title">
<h2 id="between-campuses-title">Between campuses, and from off campus</h2>
<p>Directions between the Chestnut Hill, Brighton and Newton campuses, and from places near campus, use city sidewalks and street crossings from <a href="https://www.openstreetmap.org/">OpenStreetMap</a> where BC's own paths don't reach. On campus, BC's checked paths are always used first.</p>
<ul>
<li>City sidewalks <strong>haven't been checked for accessibility</strong>. The route says how much of it is on them.</li>
<li>Each street crossing is a step of its own: <q>Cross Beacon Street at the traffic lights. There are curb ramps on both sides.</q> When curb ramps aren't recorded, the step says so, and the route says how many crossings have confirmed curb ramps. Crossings known to have a raised curb are avoided.</li>
<li>Off campus, directions name the street you're walking along, since there are no BC buildings to steer by.</li>
<li>Avoid steep slopes applies here too. Newton is hilly, so a step-free, gentle route can be much longer: the route says when a shorter, steeper one exists.</li>
</ul>
<p>The BC shuttle connects the campuses as well. Shuttle times aren't in Access BC's directions yet.</p>
</section>

<section id="blue-light" aria-labelledby="blue-light-title">
<h2 id="blue-light-title">Blue light: the nearest emergency phone</h2>
<p>Blue-light phones are the campus emergency phones that call BC Police. The <strong>Blue light</strong> button is always on screen: at the top, next to Where to?, and in the bar at the bottom while you're walking. Choose it and Access BC finds the nearest blue-light phone by walking distance from where you are, and starts walking directions to it straight away, following your location. If you're already on a trip, it replaces that trip.</p>
Expand Down
164 changes: 164 additions & 0 deletions scripts/osm-sidewalks.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Builds src/geojson/sidewalks.geojson: city sidewalks, crossings and footways from OpenStreetMap around the
// campuses, so routes can go between campuses (and start from off campus) until shuttles are in the routing.
//
// Usage: node scripts/osm-sidewalks.mjs (downloads from the public Overpass API, about 10 MB, ~30 s)
//
// The data is © OpenStreetMap contributors, under the Open Database License (ODbL): the output keeps that licence
// (see src/geojson/README.md), and the map credits OpenStreetMap. OpenStreetMap hasn't been checked for
// accessibility the way BC's own paths have, so the app says so wherever a route uses it.
//
// Each feature is one OpenStreetMap way, with properties:
// Type Sidewalk | Crossing | Street (a road tagged as having a sidewalk that isn't drawn separately) | Stairs
// name the street it runs along (sidewalks: the nearest named road), for directions
// surface, width, incline, wheelchair, tactile as tagged
// crossing marked | traffic_signals | uncontrolled | unmarked ... (crossings)
// kerb the curbs at a crossing's ends: lowered | flush | raised | unknown (the worst of them)
// rough true for unpaved or broken surfaces, which accessible routes avoid
// campus true inside a BC campus boundary, where BC's own paths are used first
// level true for bridges and tunnels, which don't join paths they pass over or under
// osm the way's id
import fs from 'node:fs';
import path from 'node:path';

const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
const OUT = path.join(ROOT, 'src/geojson/sidewalks.geojson');
// The campuses and the streets between them, with a margin for arriving from nearby (south, west, north, east)
const BBOX = [42.312, -71.207, 42.355, -71.145];
const ENDPOINTS = ['https://overpass-api.de/api/interpreter', 'https://overpass.private.coffee/api/interpreter'];

const WALKABLE = new Set(['footway', 'pedestrian', 'path', 'steps', 'living_street', 'cycleway', 'track']);
const ROADS = new Set(['residential', 'tertiary', 'secondary', 'primary', 'unclassified', 'trunk', 'service', 'primary_link', 'secondary_link', 'tertiary_link']);
const ROUGH = new Set(['dirt', 'gravel', 'fine_gravel', 'grass', 'sand', 'ground', 'earth', 'mud', 'unpaved', 'woodchips', 'pebblestone', 'rock', 'compacted', 'grass_paver']);
const KERB_RANK = { raised: 3, rolled: 2, yes: 2, unknown: 1, lowered: 0, flush: 0, no: 0 };

// The public Overpass servers are often busy: try each in turn, waiting longer each round
async function overpass(query) {
let wait = 5;
for (let round = 1; round <= 5; round++) {
for (const endpoint of ENDPOINTS) {
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'User-Agent': 'AccessBC/1.0 (Boston College accessibility map)', 'Content-Type': 'application/x-www-form-urlencoded' },
body: `data=${encodeURIComponent(query)}`,
});
if (res.ok) return (await res.json()).elements;
console.log(` ${new URL(endpoint).host}: ${res.status}`);
} catch (err) {
console.log(` ${new URL(endpoint).host}: ${err.message}`);
}
}
console.log(` busy; trying again in ${wait} s`);
await new Promise((r) => setTimeout(r, wait * 1000));
wait *= 2;
}
throw new Error('Overpass servers are busy. Try again later.');
}

// ---- Geometry (flat metres around the campus, as in src/js/routing/network.mjs) ----
const LON0 = -71.175, LAT0 = 42.335;
const KX = 111320 * Math.cos((LAT0 * Math.PI) / 180), KY = 110540;
const proj = ([lon, lat]) => [(lon - LON0) * KX, (lat - LAT0) * KY];
function distToSegment(p, a, b) {
const dx = b[0] - a[0], dy = b[1] - a[1], len2 = dx * dx + dy * dy;
const t = len2 ? Math.max(0, Math.min(1, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2)) : 0;
return Math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy);
}
function inRing(p, ring) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const [xi, yi] = ring[i], [xj, yj] = ring[j];
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) inside = !inside;
}
return inside;
}

async function main() {
const b = BBOX.join(',');
console.log('Downloading from OpenStreetMap (Overpass)…');
const elements = await overpass(`[out:json][timeout:180];
way["highway"](${b});
out body geom;
node["highway"="crossing"](${b});
out;
node["kerb"](${b});
out;
way["amenity"="university"]["name"~"Boston College"](${b});
out geom;`);

const kerbs = new Map(elements.filter((e) => e.type === 'node' && e.tags?.kerb).map((e) => [e.id, e.tags.kerb]));
const crossingNodes = new Map(elements.filter((e) => e.type === 'node' && e.tags?.highway === 'crossing').map((e) => [e.id, e.tags]));
const campuses = elements.filter((e) => e.type === 'way' && e.tags?.amenity === 'university' && e.geometry)
.map((e) => e.geometry.map((g) => proj([g.lon, g.lat])));
const ways = elements.filter((e) => e.type === 'way' && e.tags?.highway && e.geometry && !(e.tags.amenity === 'university'));
const namedRoads = ways.filter((w) => ROADS.has(w.tags.highway) && w.tags.name)
.map((w) => ({ name: w.tags.name, xy: w.geometry.map((g) => proj([g.lon, g.lat])) }));

const nearestRoadName = (xy) => {
// The road nearest the middle of the way, within 30 m
const mid = xy[Math.floor(xy.length / 2)];
let best = { d: 30, name: '' };
for (const r of namedRoads) {
for (let i = 1; i < r.xy.length; i++) {
const d = distToSegment(mid, r.xy[i - 1], r.xy[i]);
if (d < best.d) best = { d, name: r.name };
}
}
return best.name;
};

const features = [];
for (const w of ways) {
const t = w.tags;
if (['private', 'no'].includes(t.access) || t.foot === 'no' || t.area === 'yes' || t.footway === 'private') continue;
if (t.highway === 'cycleway' && !['yes', 'designated'].includes(t.foot)) continue;
if (t.highway === 'track' && !['yes', 'designated'].includes(t.foot)) continue;
let Type;
if (t.highway === 'steps') Type = 'Stairs';
else if (t.footway === 'crossing' || t.highway === 'crossing') Type = 'Crossing';
else if (WALKABLE.has(t.highway)) Type = 'Sidewalk';
else if (ROADS.has(t.highway) && ['both', 'left', 'right', 'yes'].some((v) => [t.sidewalk, t['sidewalk:both'], t['sidewalk:left'], t['sidewalk:right']].includes(v))) Type = 'Street';
else continue;

const coordinates = w.geometry.map((g) => [+g.lon.toFixed(7), +g.lat.toFixed(7)]);
const xy = coordinates.map(proj);
const props = { Type, osm: w.id };
// Sidewalks are named after the street beside them, crossings after the street they cross
const name = Type === 'Street' ? t.name : t.name || ((Type === 'Sidewalk' && t.footway === 'sidewalk') || Type === 'Crossing' ? nearestRoadName(xy) : '');
if (name) props.name = name;
for (const k of ['surface', 'width', 'incline', 'wheelchair']) if (t[k]) props[k] = t[k];
if (t.tactile_paving) props.tactile = t.tactile_paving;
if (ROUGH.has(t.surface) || ['bad', 'very_bad', 'horrible', 'very_horrible', 'impassable'].includes(t.smoothness)) props.rough = true;
if (t.bridge || t.tunnel || (t.layer && t.layer !== '0')) props.level = true;
if (Type === 'Crossing') {
const node = w.nodes.map((id) => crossingNodes.get(id)).find(Boolean) || {};
props.crossing = t.crossing || node.crossing || 'unknown';
if (t['crossing:signals'] === 'yes' || node.crossing === 'traffic_signals') props.crossing = 'traffic_signals';
// The curbs at each end: the first and last nodes, or tagged kerb nodes along the way
const ends = [w.nodes[0], w.nodes.at(-1)].map((id) => kerbs.get(id) || 'unknown');
const along = w.nodes.map((id) => kerbs.get(id)).filter(Boolean);
const all = along.length ? [...along, ...(along.length < 2 ? ['unknown'] : [])] : ends;
props.kerb = all.reduce((worst, k) => ((KERB_RANK[k] ?? 1) > (KERB_RANK[worst] ?? 1) ? k : worst));
if (props.kerb === 'yes' || props.kerb === 'rolled') props.kerb = 'raised';
if (props.kerb === 'no') props.kerb = 'flush';
}
if (campuses.some((ring) => xy.filter((p) => inRing(p, ring)).length > xy.length / 2)) props.campus = true;
features.push({ type: 'Feature', properties: props, geometry: { type: 'LineString', coordinates } });
}

const out = {
type: 'FeatureCollection',
license: 'ODbL-1.0',
attribution: '© OpenStreetMap contributors, https://www.openstreetmap.org/copyright',
generated: new Date().toISOString().slice(0, 10),
bbox: [BBOX[1], BBOX[0], BBOX[3], BBOX[2]],
features,
};
fs.writeFileSync(OUT, `{"type":"FeatureCollection","license":${JSON.stringify(out.license)},"attribution":${JSON.stringify(out.attribution)},"generated":"${out.generated}","bbox":${JSON.stringify(out.bbox)},"features":[\n${features.map((f) => JSON.stringify(f)).join(',\n')}\n]}\n`);
const count = (k) => features.filter((f) => f.properties.Type === k).length;
console.log(`Wrote ${path.relative(ROOT, OUT)}: ${features.length} ways (${count('Sidewalk')} sidewalks and footways, ${count('Crossing')} crossings, ${count('Street')} streets with sidewalks, ${count('Stairs')} steps), ${(fs.statSync(OUT).size / 1e6).toFixed(1)} MB`);
console.log(`Crossings by curb: ${JSON.stringify(Object.fromEntries(['lowered', 'flush', 'raised', 'unknown'].map((k) => [k, features.filter((f) => f.properties.kerb === k).length])))}`);
console.log(`On campus: ${features.filter((f) => f.properties.campus).length}; rough: ${features.filter((f) => f.properties.rough).length}`);
}

main().catch((err) => { console.error(err); process.exit(1); });
17 changes: 17 additions & 0 deletions src/geojson/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ It's the main roof height, so towers and spires aren't included. 258 of 298 buil
small garages and sheds with too few lidar points, so they're `null` (the 3D view shows them at 3 m). Re-run
the script after adding or reshaping buildings.

## City sidewalks (`sidewalks.geojson`)

City sidewalks, street crossings and footways around the campuses, from OpenStreetMap, built by
[scripts/osm-sidewalks.mjs](../../scripts/osm-sidewalks.mjs). Routes use them only when BC's own paths can't make
the trip: between campuses, or from off campus. The app loads the file only then.

**Licence:** © OpenStreetMap contributors, under the [Open Database License (ODbL)](https://opendatacommons.org/licenses/odbl/).
Unlike the rest of this folder, this file must stay under the ODbL, and anything derived from it must be shared
under the ODbL too. The map credits OpenStreetMap.

Each feature is one OpenStreetMap way. `Type` is `Sidewalk`, `Crossing`, `Street` (a road tagged as having a
sidewalk that isn't drawn separately), or `Stairs`. Crossings carry `crossing` (the kind of crosswalk) and `kerb`
(the worst curb at either end: `lowered`, `flush`, `raised`, or `unknown`). `rough`, `campus`, and `level` mark
unpaved ways, ways inside a BC campus boundary, and bridges and tunnels. None of it has been checked for
accessibility, and the app says so on every route that uses it. Many curb ramps aren't tagged in OpenStreetMap
yet; adding them there (`kerb=lowered` on the crossing's end nodes) improves the routes after the next refresh.

## Known quirks

- Field names were cut to 10 characters by the shapefile export (`Descriptio`, `Food_dinin`,
Expand Down
Loading
Loading