Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bus stop routes overview: colours distinguishing up/downroute #64

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
7 changes: 7 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all",
"arrowParens": "always",
"singleQuote": true
}
161 changes: 107 additions & 54 deletions assets/app.js
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ import getRoute from './utils/getRoute';
import getDistance from './utils/getDistance';
import getWalkingMinutes from './utils/getWalkingMinutes';
import usePrevious from './utils/usePrevious';
import { getGeometriesForLoop } from './utils/routesCalculation';

import Ad from './ad';
import About from './components/About';
@@ -537,13 +538,22 @@ const App = () => {
previewRAF = requestAnimationFrame(() => {
const routes = routesData[service];
const geometries = routes.map((route) => toGeoJSON(route));

const { name: serviceName, routes: serviceStops } = servicesData[service];
const isLoop = serviceName.includes('⟲');

const geometriesToBeMapped = isLoop
? getGeometriesForLoop(serviceStops, geometries, stopsData, ruler)
: geometries;

map.getSource('routes-path').setData({
type: 'FeatureCollection',
features: geometries.map((geometry) => ({
features: geometriesToBeMapped.map((geometry, direction) => ({
type: 'Feature',
id: encode(service),
properties: {
service,
direction,
},
geometry,
})),
@@ -815,11 +825,22 @@ const App = () => {
requestAnimationFrame(() => {
const routes = routesData[service];
const geometries = routes.map((route) => toGeoJSON(route));

const { name: serviceName, routes: serviceStops } =
servicesData[service];
const isLoop = serviceName.includes('⟲');

const geometriesToBeMapped = isLoop
? getGeometriesForLoop(serviceStops, geometries, stopsData, ruler)
: geometries;

map.getSource('routes').setData({
type: 'FeatureCollection',
features: geometries.map((geometry) => ({
features: geometriesToBeMapped.map((geometry, direction) => ({
type: 'Feature',
properties: {},
properties: {
direction,
},
geometry,
})),
});
@@ -840,23 +861,40 @@ const App = () => {
let endStops = [];
let serviceGeometries = [];
services.forEach((service) => {
const { routes } = servicesData[service];
endStops.push(routes[0][0], routes[0][routes[0].length - 1]);
if (routes[1]) {
endStops.push(routes[1][0], routes[1][routes[1].length - 1]);
}
const allRoutes = [...routes[0], ...(routes[1] || [])].filter(
(el, pos, arr) => {
return arr.indexOf(el) === pos;
},
const { name: serviceName, routes: serviceStops } =
servicesData[service];
endStops.push(
serviceStops[0][0],
serviceStops[0][serviceStops[0].length - 1],
);
if (serviceStops[1]) {
endStops.push(
serviceStops[1][0],
serviceStops[1][serviceStops[1].length - 1],
);
}
const allRoutes = [
...serviceStops[0],
...(serviceStops[1] || []),
].filter((el, pos, arr) => {
return arr.indexOf(el) === pos;
});
routeStops = routeStops.concat(allRoutes);

const routeGeometries = routesData[service];
const routes = routesData[service];
const geometries = routes.map((route) => toGeoJSON(route));

const isLoop = serviceName.includes('⟲');

const routeGeometries = isLoop
? getGeometriesForLoop(serviceStops, geometries, stopsData, ruler)
: geometries;

serviceGeometries = serviceGeometries.concat(
routeGeometries.map((r) => ({
routeGeometries.map((geometry, direction) => ({
service,
geometry: toGeoJSON(r),
geometry,
direction,
})),
);
});
@@ -932,14 +970,17 @@ const App = () => {
requestAnimationFrame(() => {
map.getSource('routes-path').setData({
type: 'FeatureCollection',
features: serviceGeometries.map((sg) => ({
type: 'Feature',
id: encode(sg.service),
properties: {
service: sg.service,
},
geometry: sg.geometry,
})),
features: serviceGeometries.map(
({ service, geometry, direction }) => ({
type: 'Feature',
id: encode(service),
properties: {
service,
direction,
},
geometry,
}),
),
});
STORE.routesPathServices = serviceGeometries.map(
(sg) => sg.service,
@@ -1034,26 +1075,44 @@ const App = () => {

// Show all routes
requestAnimationFrame(() => {
const serviceGeometries = routes.map((route) => {
const [service, index] = route.split('-');
const line = routesData[service][index];
const geometry = toGeoJSON(line);
return {
const serviceGeometries = routes.flatMap((passingRoute) => {
const [service, index] = passingRoute.split('-');
const route = routesData[service][index];
const geometry = toGeoJSON(route);

const { name: serviceName, routes: serviceStops } =
servicesData[service];
const isLoop = serviceName.includes('⟲');

const routeGeometries = isLoop
? getGeometriesForLoop(
serviceStops,
[geometry],
stopsData,
ruler,
)
: [geometry];

return routeGeometries.map((routeGeometry, directionIndex) => ({
service,
geometry,
};
geometry: routeGeometry,
direction: isLoop ? directionIndex : parseInt(index),
}));
});

map.getSource('routes-path').setData({
type: 'FeatureCollection',
features: serviceGeometries.map((sg, i) => ({
type: 'Feature',
id: encode(sg.service),
properties: {
service: sg.service,
},
geometry: sg.geometry,
})),
features: serviceGeometries.map(
({ service, geometry, direction }) => ({
type: 'Feature',
id: encode(service),
properties: {
service,
direction,
},
geometry,
}),
),
});
STORE.routesPathServices = serviceGeometries.map(
(sg) => sg.service,
@@ -1845,17 +1904,14 @@ const App = () => {
'line-cap': 'round',
},
paint: {
'line-color': '#f01b48',
'line-gradient': [
'interpolate',
['linear'],
['line-progress'],
'line-color': [
'match',
['get', 'direction'],
0,
'#f01b48',
0.5,
'#972FFE',
1,
'#f01b48',
'#05A8AA',
'#000000',
],
'line-opacity': [
'interpolate',
@@ -1980,17 +2036,14 @@ const App = () => {
'line-cap': 'round',
},
paint: {
'line-color': '#f01b48',
'line-gradient': [
'interpolate',
['linear'],
['line-progress'],
'line-color': [
'match',
['get', 'direction'],
0,
'#f01b48',
0.5,
'#972FFE',
1,
'#f01b48',
'#05A8AA',
'#000000',
],
'line-opacity': [
'case',
234 changes: 10 additions & 224 deletions assets/components/StopsList.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { h, Fragment } from 'preact';
import { useRef } from 'preact/hooks';

import { getStopGridForNormalOrLoopRoute } from '../utils/routesCalculation';

function rowSpaner(stopGrid, column, stop) {
if (!stop) return 1;
let span = 1;
@@ -13,22 +15,6 @@ function rowSpaner(stopGrid, column, stop) {
return span;
}

function isOpposite(stop) {
return /[19]$/.test(stop);
}

function getOpposite(stop) {
if (isOpposite(stop)) {
return stop.replace(/[19]$/, (d) => (d === '1' ? 9 : 1));
}
return null;
}

function areOpposite(stop1, stop2) {
if (!stop1 || !stop2) return false;
return stop1 !== stop2 && stop1 === getOpposite(stop2);
}

export default function StopsList(props) {
const { routes, stopsData, onStopClick, onStopClickAgain } = props;
if (
@@ -110,220 +96,20 @@ export default function StopsList(props) {
</ol>
);
} else {
// Complex table layout for complex routes yo
const stopGrid = [];

// Create mutable copies
let route1Copy, route2Copy;
let loopRoute = false; // A->A
if (route2) {
route1Copy = route1.slice();
route2Copy = route2.slice().reverse(); // Reverse for easier index reference
} else {
if (!route2) {
// Mostly likely a loop route A⟲B
// So, split route1 into two routes
loopRoute = true;
let half;
let hasMidStop = false;

const hasDupStops = [];
for (let i = 1, j = route1Len - 2; route1Len > 3 && !half; i++, j--) {
const stop = route1[i];
// console.log(i, j, route1, 1, stop);

if (isOpposite(stop)) {
// WEIRD CASE: the route has duplicate stops!
// Don't check first item because last item is its duplicate in loop route
if (i !== route1.lastIndexOf(stop)) {
// TODO: Handle this usecase?
// Do nothing for now
hasDupStops.push(stop);
} else {
// Normal case, let's find the middle index
const opStop = getOpposite(route1[i]);
const index = route1.lastIndexOf(opStop);
if (index > 0) j = index;
}
}
if (i === j - 1) {
half = j;
} else if (i === j) {
half = j;
hasMidStop = true;
}
}

if (hasDupStops.length) {
console.info('This loop route has duplicate stops.', hasDupStops);
}

route1Copy = route1.slice(0, half);
route2Copy = route1
.slice(-(route1Len - half - (hasMidStop ? 1 : 0)))
.reverse();
// console.log('x', half, route1, route1Copy, route2Copy);

/*
const half = Math.floor(route1Len / 2);
route1Copy = route1.slice(0, half);
route2Copy = route1.slice(-half).reverse();
*/

if (hasMidStop) {
// Odd
const midStop = route1[half];
console.log({ midStop });
if (areOpposite(midStop, route1[half - 1])) {
// ~~~ = Dummy stop to connect at the end
route2Copy.push(midStop, '~~~');
route1Copy.push('~~~');
} else if (areOpposite(midStop, route1[half + 1])) {
route1Copy.push(midStop, '~~~');
route2Copy.push('~~~');
} else {
// Throw in both because it'll be merged and rendered as a single stop later
route1Copy.push(midStop);
route2Copy.push(midStop);
}
} else {
// Even
route1Copy.push('~~~');
route2Copy.push('~~~');
}
}
console.log({
route1Copy: route1Copy.slice(),
route2Copy: route2Copy.slice(),
});

// const oppositeStops = [];
// route1Copy.forEach((s, i) => {
// const opS = getOpposite(s);
// if (opS) {
// if (
// route2Copy.includes(opS) &&
// !route1Copy.includes(opS) &&
// !route2Copy.includes(s)
// ) {
// oppositeStops.push(s, opS);
// }
// }
// });

let i = 0;
const maxLoopCount = route1Copy.length + route2Copy.length;
let col1IsEmpty = true,
col2IsEmpty = true;
let col1FirstStop = false,
col2FirstStop = false,
col1LastStop = false,
col2LastStop = false;
do {
const stop1HasOpposite = route2Copy.includes(getOpposite(route1Copy[0]));
const stop2HasOpposite = route1Copy.includes(getOpposite(route2Copy[0]));
const stop1IsLast = route1Copy.length === 1 || !route1Copy.length;
const stop2IsLast = route2Copy.length === 1 || !route2Copy.length;

if (loopRoute && i === 0) {
stopGrid[i] = [route1Copy.shift(), route2Copy.shift()];
} else if (
(stop1HasOpposite && !stop2HasOpposite) ||
(stop1IsLast && !stop1HasOpposite && !stop2IsLast && !stop2HasOpposite)
) {
stopGrid[i] = [null, route2Copy.shift()];
} else if (
(!stop1HasOpposite && stop2HasOpposite) ||
(stop2IsLast && !stop2HasOpposite && !stop1IsLast && !stop1HasOpposite)
) {
stopGrid[i] = [route1Copy.shift(), null];
} else {
stopGrid[i] = [route1Copy.shift(), route2Copy.shift()];
}

// Check empty columns
if (stopGrid[i]?.[0] && col1IsEmpty) {
col1IsEmpty = false;
} else if (!stopGrid[i]?.[0] && !route1Copy.length) {
col1IsEmpty = true;
}
if (stopGrid[i]?.[1] && col2IsEmpty) {
col2IsEmpty = false;
} else if (!stopGrid[i]?.[1] && !route2Copy.length) {
col2IsEmpty = true;
}

// Extra metadata
const metadata = {
isOpposite: areOpposite(stopGrid[i][0], stopGrid[i][1]),
col1IsEmpty,
col2IsEmpty,
};

// Check first/last stops
if (stopGrid[i]?.[0] && !col1FirstStop) {
metadata.col1FirstStop = col1FirstStop = true;
}
if (stopGrid[i]?.[1] && !col2FirstStop) {
metadata.col2FirstStop = col2FirstStop = true;
}
if (!route1Copy.length && !col1LastStop) {
metadata.col1LastStop = col1LastStop = true;
}
if (!route2Copy.length && !col2LastStop) {
metadata.col2LastStop = col2LastStop = true;
}

// Final metadata push
stopGrid[i]?.push(metadata);

// Infinite loop guard
if (i > maxLoopCount + 2) {
console.error(
'Something is wrong. Might be infinite loop',
i,
route1Copy,
route2Copy,
stopGrid,
);
return null;
}
i++;
} while (route1Copy[0] || route2Copy[0]);

// Postfix for loop routes (A->A)
if (loopRoute) {
const lastRow = stopGrid[stopGrid.length - 1];
const last2ndRow = stopGrid[stopGrid.length - 2];
if (lastRow[0] === lastRow[1] && last2ndRow?.includes(null)) {
const [s1, s2, meta] = last2ndRow;
if (lastRow[0] === '~~~') {
const theStop = /\d/.test(s1) ? s1 : s2;
stopGrid[stopGrid.length - 2] = [
theStop,
theStop,
{
...meta,
col1LastStop: true,
col2LastStop: true,
},
];
stopGrid.pop();
} else if (lastRow[0]) {
if (!s1) last2ndRow[0] = lastRow[0];
if (!s2) last2ndRow[1] = lastRow[0];
if (!s1 || !s2) {
stopGrid[stopGrid.length - 1] = [
'~~~',
'~~~',
{
col1LastStop: true,
col2LastStop: true,
},
];
}
}
}
}
// Complex table layout for complex routes yo
const stopGrid = getStopGridForNormalOrLoopRoute(
route1,
route2,
route1Len,
loopRoute,
);

console.log({ route1, route2, stopGrid });

321 changes: 321 additions & 0 deletions assets/utils/routesCalculation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
function isOpposite(stop) {
return /[19]$/.test(stop);
}

function getOpposite(stop) {
if (isOpposite(stop)) {
return stop.replace(/[19]$/, (d) => (d === '1' ? 9 : 1));
}
return null;
}

function areOpposite(stop1, stop2) {
if (!stop1 || !stop2) return false;
return stop1 !== stop2 && stop1 === getOpposite(stop2);
}

const findLoopHalfpoint = (route1, route1Len) => {
let half;
let hasMidStop = false;

const hasDupStops = [];
for (let i = 1, j = route1Len - 2; route1Len > 3 && !half; i++, j--) {
const stop = route1[i];
// console.log(i, j, route1, 1, stop);

if (isOpposite(stop)) {
// WEIRD CASE: the route has duplicate stops!
// Don't check first item because last item is its duplicate in loop route
if (i !== route1.lastIndexOf(stop)) {
// TODO: Handle this usecase?
// Do nothing for now
hasDupStops.push(stop);
} else {
// Normal case, let's find the middle index
const opStop = getOpposite(route1[i]);
const index = route1.lastIndexOf(opStop);
if (index > 0) j = index;
}
}
if (i === j - 1) {
half = j;
} else if (i === j) {
half = j;
hasMidStop = true;
}
}

return [half, hasMidStop, hasDupStops];
};

const copyRouteOrDivideAndCopyLoopRoute = (route1, route2, route1Len) => {
// Create mutable copies
let route1Copy, route2Copy;
if (route2) {
route1Copy = route1.slice();
route2Copy = route2.slice().reverse(); // Reverse for easier index reference
} else {
// Mostly likely a loop route A⟲B
// So, split route1 into two routes
// loopRoute = true;

const [half, hasMidStop, hasDupStops] = findLoopHalfpoint(
route1,
route1Len,
);

if (hasDupStops.length) {
console.info('This loop route has duplicate stops.', hasDupStops);
}

route1Copy = route1.slice(0, half);
route2Copy = route1
.slice(-(route1Len - half - (hasMidStop ? 1 : 0)))
.reverse();
// console.log('x', half, route1, route1Copy, route2Copy);

/*
const half = Math.floor(route1Len / 2);
route1Copy = route1.slice(0, half);
route2Copy = route1.slice(-half).reverse();
*/

if (hasMidStop) {
// Odd
const midStop = route1[half];
console.log({ midStop });
if (areOpposite(midStop, route1[half - 1])) {
// ~~~ = Dummy stop to connect at the end
route2Copy.push(midStop, '~~~');
route1Copy.push('~~~');
} else if (areOpposite(midStop, route1[half + 1])) {
route1Copy.push(midStop, '~~~');
route2Copy.push('~~~');
} else {
// Throw in both because it'll be merged and rendered as a single stop later
route1Copy.push(midStop);
route2Copy.push(midStop);
}
} else {
// Even
route1Copy.push('~~~');
route2Copy.push('~~~');
}
}

return [route1Copy, route2Copy];
};

export const getStopGridForNormalOrLoopRoute = (
route1,
route2,
route1Len,
loopRoute,
) => {
const stopGrid = [];

const [route1Copy, route2Copy] = copyRouteOrDivideAndCopyLoopRoute(
route1,
route2,
route1Len,
);

console.log({
route1Copy: route1Copy.slice(),
route2Copy: route2Copy.slice(),
});

// const oppositeStops = [];
// route1Copy.forEach((s, i) => {
// const opS = getOpposite(s);
// if (opS) {
// if (
// route2Copy.includes(opS) &&
// !route1Copy.includes(opS) &&
// !route2Copy.includes(s)
// ) {
// oppositeStops.push(s, opS);
// }
// }
// });

let i = 0;
const maxLoopCount = route1Copy.length + route2Copy.length;
let col1IsEmpty = true,
col2IsEmpty = true;
let col1FirstStop = false,
col2FirstStop = false,
col1LastStop = false,
col2LastStop = false;
do {
const stop1HasOpposite = route2Copy.includes(getOpposite(route1Copy[0]));
const stop2HasOpposite = route1Copy.includes(getOpposite(route2Copy[0]));
const stop1IsLast = route1Copy.length === 1 || !route1Copy.length;
const stop2IsLast = route2Copy.length === 1 || !route2Copy.length;

if (loopRoute && i === 0) {
stopGrid[i] = [route1Copy.shift(), route2Copy.shift()];
} else if (
(stop1HasOpposite && !stop2HasOpposite) ||
(stop1IsLast && !stop1HasOpposite && !stop2IsLast && !stop2HasOpposite)
) {
stopGrid[i] = [null, route2Copy.shift()];
} else if (
(!stop1HasOpposite && stop2HasOpposite) ||
(stop2IsLast && !stop2HasOpposite && !stop1IsLast && !stop1HasOpposite)
) {
stopGrid[i] = [route1Copy.shift(), null];
} else {
stopGrid[i] = [route1Copy.shift(), route2Copy.shift()];
}

// Check empty columns
if (stopGrid[i]?.[0] && col1IsEmpty) {
col1IsEmpty = false;
} else if (!stopGrid[i]?.[0] && !route1Copy.length) {
col1IsEmpty = true;
}
if (stopGrid[i]?.[1] && col2IsEmpty) {
col2IsEmpty = false;
} else if (!stopGrid[i]?.[1] && !route2Copy.length) {
col2IsEmpty = true;
}

// Extra metadata
const metadata = {
isOpposite: areOpposite(stopGrid[i][0], stopGrid[i][1]),
col1IsEmpty,
col2IsEmpty,
};

// Check first/last stops
if (stopGrid[i]?.[0] && !col1FirstStop) {
metadata.col1FirstStop = col1FirstStop = true;
}
if (stopGrid[i]?.[1] && !col2FirstStop) {
metadata.col2FirstStop = col2FirstStop = true;
}
if (!route1Copy.length && !col1LastStop) {
metadata.col1LastStop = col1LastStop = true;
}
if (!route2Copy.length && !col2LastStop) {
metadata.col2LastStop = col2LastStop = true;
}

// Final metadata push
stopGrid[i]?.push(metadata);

// Infinite loop guard
if (i > maxLoopCount + 2) {
console.error(
'Something is wrong. Might be infinite loop',
i,
route1Copy,
route2Copy,
stopGrid,
);
return null;
}
i++;
} while (route1Copy[0] || route2Copy[0]);

// Postfix for loop routes (A->A)
if (loopRoute) {
const lastRow = stopGrid[stopGrid.length - 1];
const last2ndRow = stopGrid[stopGrid.length - 2];
if (lastRow[0] === lastRow[1] && last2ndRow?.includes(null)) {
const [s1, s2, meta] = last2ndRow;

if (lastRow[0] === '~~~') {
const theStop = /\d/.test(s1) ? s1 : s2;
stopGrid[stopGrid.length - 2] = [
theStop,
theStop,
{
...meta,
col1LastStop: true,
col2LastStop: true,
},
];
stopGrid.pop();
} else if (lastRow[0]) {
if (!s1) last2ndRow[0] = lastRow[0];
if (!s2) last2ndRow[1] = lastRow[0];
if (!s1 || !s2) {
stopGrid[stopGrid.length - 1] = [
'~~~',
'~~~',
{
col1LastStop: true,
col2LastStop: true,
},
];
}
}
}
}

return stopGrid;
};

export const getGeometriesForLoop = (
serviceStops,
geometries,
stopsData,
ruler,
) => {
const loopStops = serviceStops[0];
const loopGeometries = geometries[0];

const [half, hasMidStop] = findLoopHalfpoint(loopStops, loopStops.length);

let midStopCoordinate;
if (hasMidStop) {
const midStop = loopStops[half];

midStopCoordinate = stopsData[midStop].coordinates;
} else {
const lastStopOfFirstHalfOfLoop = loopStops[half - 1];
const firstStopOfSecondHalfOfLoop = loopStops[half];

const lastStopFirstHalfCoordinates =
stopsData[lastStopOfFirstHalfOfLoop].coordinates;
const firstStopSecondHalfCoordinates =
stopsData[firstStopOfSecondHalfOfLoop].coordinates;

const middleSegment = ruler.lineSlice(
lastStopFirstHalfCoordinates,
firstStopSecondHalfCoordinates,
loopGeometries.coordinates,
);

const middleSegmentLength = ruler.lineDistance(middleSegment);

midStopCoordinate = ruler.along(middleSegment, middleSegmentLength / 2);
}

const { point: interpolatedCoordinate, index: interpolationSegmentIndex } =
ruler.pointOnLine(loopGeometries.coordinates, midStopCoordinate);

const newGeometries = [loopGeometries, loopGeometries];
const splittedNewGeometries = newGeometries.map(
({ type, coordinates }, index) =>
!index
? {
type,
coordinates: [
...coordinates.slice(0, interpolationSegmentIndex + 1),
interpolatedCoordinate,
],
}
: {
type,
coordinates: [
interpolatedCoordinate,
...coordinates.slice(interpolationSegmentIndex + 1),
],
},
);

return splittedNewGeometries;
};
1 change: 1 addition & 0 deletions package-lock.json