diff --git a/website/package.json b/website/package.json index 544974fec0..0e60c59b61 100644 --- a/website/package.json +++ b/website/package.json @@ -146,6 +146,7 @@ "json2mq": "0.2.0", "leaflet": "1.9.4", "leaflet-gesture-handling": "1.2.2", + "leaflet-polylineoffset": "1.1.1", "lodash": "4.17.21", "mousetrap": "1.6.5", "nusmoderator": "3.0.0", diff --git a/website/src/apis/nextbus-new.ts b/website/src/apis/nextbus-new.ts new file mode 100644 index 0000000000..00f55b7a1e --- /dev/null +++ b/website/src/apis/nextbus-new.ts @@ -0,0 +1,26 @@ +const baseURL = 'https://nnextbus.nusmods.com'; // TODO: wait until we have an api proxy + +export const getStopTimings = async ( + stop: string, + callback?: (data: ShuttleServiceResult) => void, + error?: (e: unknown) => void, +) => { + if (!stop) return; + // TODO: wait until we have an api proxy + // const API_AUTH = ''; + try { + const headers = { + // headers: { + // authorization: API_AUTH, + // accept: 'application/json', + // }, + }; + const response = await fetch(`${baseURL}/ShuttleService?busstopname=${stop}`, headers); + const data = await response.json(); + // console.log(data); + if (callback) callback(data.ShuttleServiceResult); + } catch (e) { + // console.error(e); + if (error) error(e); + } +}; diff --git a/website/src/data/isb-services.json b/website/src/data/isb-services.json new file mode 100644 index 0000000000..485b1a8e8e --- /dev/null +++ b/website/src/data/isb-services.json @@ -0,0 +1,2421 @@ +[ + { + "id": "a1", + "name": "A1", + "color": "#E43737", + "color2": "#EDA9AB", + "color2dark": "#742F2F", + "stops": [ + "KRB", + "LT13", + "AS5", + "BIZ2", + "TCOMS-OPP", + "PGP", + "KR-MRT", + "LT27", + "UHALL", + "UHC-OPP", + "YIH", + "CLB", + "KRB" + ], + "notableStops": ["PGP", "KR-MRT", "CLB"], + "schedule": { + "term": [ + [ + { + "from": "09:00", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "10:00", + "interval": [4, 6] + }, + { + "from": "10:01", + "to": "11:00", + "interval": [10, 12] + }, + { + "from": "11:01", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [8, 10] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "10:00", + "interval": [4, 6] + }, + { + "from": "10:01", + "to": "11:00", + "interval": [10, 12] + }, + { + "from": "11:01", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [8, 10] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "10:00", + "interval": [4, 6] + }, + { + "from": "10:01", + "to": "11:00", + "interval": [10, 12] + }, + { + "from": "11:01", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [8, 10] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "10:00", + "interval": [4, 6] + }, + { + "from": "10:01", + "to": "11:00", + "interval": [10, 12] + }, + { + "from": "11:01", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [8, 10] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "10:00", + "interval": [4, 6] + }, + { + "from": "10:01", + "to": "11:00", + "interval": [10, 12] + }, + { + "from": "11:01", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [8, 10] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ], + "vacation": [ + [ + { + "from": "09:00", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ] + } + }, + { + "id": "a2", + "name": "A2", + "color": "#FF9E0C", + "color2": "#F8D29A", + "color2dark": "#7F581D", + "stops": [ + "KRB", + "IT", + "YIH-OPP", + "MUSEUM", + "UHC", + "UHALL-OPP", + "S17", + "KR-MRT-OPP", + "PGPR", + "TCOMS", + "HSSML-OPP", + "NUSS-OPP", + "LT13-OPP", + "KRB" + ], + "notableStops": ["IT", "MUSEUM", "KR-MRT-OPP", "PGPR"], + "schedule": { + "term": [ + [ + { + "from": "09:00", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [6, 8] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [6, 8] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [7, 9] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [6, 8] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 13] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [6, 8] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [6, 8] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [7, 9] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [6, 8] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 13] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [6, 8] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [6, 8] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [7, 9] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [6, 8] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 13] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [6, 8] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [6, 8] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [7, 9] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [6, 8] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 13] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [6, 8] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [6, 8] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [7, 9] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [6, 8] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 13] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ], + "vacation": [ + [ + { + "from": "09:00", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ] + } + }, + { + "id": "d1", + "name": "D1", + "color": "#EE59A1", + "color2": "#F1B7D5", + "color2dark": "#783C59", + "stops": [ + "COM3", + "HSSML-OPP", + "NUSS-OPP", + "LT13-OPP", + "IT", + "YIH-OPP", + "MUSEUM", + "UTOWN", + "YIH", + "CLB", + "LT13", + "AS5", + "BIZ2", + "COM3" + ], + "notableStops": ["IT", "MUSEUM", "UTOWN"], + "schedule": { + "term": [ + [ + { + "from": "09:05", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "09:15", + "interval": [6, 8] + }, + { + "from": "09:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [6, 8] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 14] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "09:15", + "interval": [6, 8] + }, + { + "from": "09:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [6, 8] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 14] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "09:15", + "interval": [6, 8] + }, + { + "from": "09:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [6, 8] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 14] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "09:15", + "interval": [6, 8] + }, + { + "from": "09:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [6, 8] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 14] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [8, 10] + }, + { + "from": "08:01", + "to": "09:15", + "interval": [6, 8] + }, + { + "from": "09:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [6, 8] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [11, 14] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:20", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ], + "vacation": [ + [ + { + "from": "09:05", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:20", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ] + } + }, + { + "id": "d2", + "name": "D2", + "color": "#8C56CF", + "color2": "#CAB5E8", + "color2dark": "#513B6B", + "stops": [ + "COM3", + "TCOMS-OPP", + "PGP", + "KR-MRT", + "LT27", + "UHALL", + "UHC-OPP", + "MUSEUM", + "UTOWN", + "UHC", + "UHALL-OPP", + "S17", + "KR-MRT-OPP", + "PGPR", + "TCOMS", + "COM3" + ], + "notableStops": ["PGP", "KR-MRT", "MUSEUM", "UTOWN"], + "schedule": { + "term": [ + [ + { + "from": "09:05", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [5, 7] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [7, 9] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [10, 12] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [5, 7] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [7, 9] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [10, 12] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [5, 7] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [7, 9] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [10, 12] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [5, 7] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [7, 9] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [10, 12] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:15", + "to": "10:00", + "interval": [5, 7] + }, + { + "from": "10:01", + "to": "11:15", + "interval": [9, 11] + }, + { + "from": "11:16", + "to": "14:00", + "interval": [5, 7] + }, + { + "from": "14:01", + "to": "17:15", + "interval": [7, 9] + }, + { + "from": "17:16", + "to": "19:30", + "interval": [5, 7] + }, + { + "from": "19:31", + "to": "21:30", + "interval": [10, 12] + }, + { + "from": "21:31", + "to": "23:00", + "interval": [15] + } + ], + [ + { + "from": "07:20", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ], + "vacation": [ + [ + { + "from": "09:05", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:15", + "to": "08:00", + "interval": [15] + }, + { + "from": "08:01", + "to": "09:45", + "interval": [7, 9] + }, + { + "from": "09:46", + "to": "11:45", + "interval": [15] + }, + { + "from": "11:46", + "to": "14:00", + "interval": [7, 9] + }, + { + "from": "14:01", + "to": "17:00", + "interval": [15] + }, + { + "from": "17:01", + "to": "19:30", + "interval": [7, 9] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ], + [ + { + "from": "07:20", + "to": "19:30", + "interval": [15] + }, + { + "from": "19:31", + "to": "23:00", + "interval": [30] + } + ] + ] + } + }, + { + "id": "e", + "name": "E", + "color": "#16AB52", + "color2": "#9BD7B6", + "color2dark": "#215D39", + "stops": ["UTOWN", "RAFFLES", "KV", "EA", "SDE3", "IT", "YIH-OPP", "UTOWN"], + "notableStops": ["KV", "SDE3", "IT"], + "schedule": { + "term": [ + [], + [ + { + "from": "08:00", + "to": "10:00", + "interval": [15] + }, + { + "from": "11:15", + "to": "14:00", + "interval": [15] + }, + { + "from": "15:15", + "to": "16:00", + "interval": [15] + } + ], + [ + { + "from": "08:00", + "to": "10:00", + "interval": [15] + }, + { + "from": "11:15", + "to": "14:00", + "interval": [15] + }, + { + "from": "15:15", + "to": "16:00", + "interval": [15] + } + ], + [ + { + "from": "08:00", + "to": "10:00", + "interval": [15] + }, + { + "from": "11:15", + "to": "14:00", + "interval": [15] + }, + { + "from": "15:15", + "to": "16:00", + "interval": [15] + } + ], + [ + { + "from": "08:00", + "to": "10:00", + "interval": [15] + }, + { + "from": "11:15", + "to": "14:00", + "interval": [15] + }, + { + "from": "15:15", + "to": "16:00", + "interval": [15] + } + ], + [ + { + "from": "08:00", + "to": "10:00", + "interval": [15] + }, + { + "from": "11:15", + "to": "14:00", + "interval": [15] + }, + { + "from": "15:15", + "to": "16:00", + "interval": [15] + } + ], + [] + ], + "vacation": [[], [], [], [], [], [], []] + } + }, + { + "id": "k", + "name": "K", + "color": "#3053D0", + "color2": "#A5B4E8", + "color2dark": "#2C3A6C", + "stops": [ + "PGP", + "KR-MRT", + "LT27", + "UHALL", + "UHC-OPP", + "YIH", + "CLB", + "SDE3-OPP", + "JP-SCH-16151", + "KV", + "MUSEUM", + "UHC", + "UHALL-OPP", + "S17", + "KR-MRT-OPP", + "PGPR" + ], + "notableStops": ["KR-MRT", "CLB", "KV", "MUSEUM"], + "schedule": { + "term": [ + [], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "19:44", + "interval": [40] + } + ] + ], + "vacation": [ + [], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "23:04", + "interval": [40] + } + ], + [ + { + "from": "07:04", + "to": "19:44", + "interval": [40] + } + ] + ] + } + }, + { + "id": "btc", + "name": "BTC", + "color": "#F56700", + "color2": "#F4BC95", + "color2dark": "#7B4219", + "stops": [ + "OTH", + "BG-MRT", + "KR-MRT", + "LT27", + "UHALL", + "UHC-OPP", + "UTOWN", + "RAFFLES", + "KV", + "MUSEUM", + "YIH", + "CLB", + "LT13", + "AS5", + "BIZ2", + "PGP", + "CG", + "OTH" + ], + "notableStops": ["BG-MRT", "KR-MRT", "UTOWN", "KV", "CLB"], + "schedule": { + "term": [ + [], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + }, + { + "from": "19:41", + "to": "21:10", + "interval": [45] + }, + { + "from": "21:11", + "to": "21:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + }, + { + "from": "19:41", + "to": "21:10", + "interval": [45] + }, + { + "from": "21:11", + "to": "21:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + }, + { + "from": "19:41", + "to": "21:10", + "interval": [45] + }, + { + "from": "21:11", + "to": "21:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + }, + { + "from": "19:41", + "to": "21:10", + "interval": [45] + }, + { + "from": "21:11", + "to": "21:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + }, + { + "from": "19:41", + "to": "21:10", + "interval": [45] + }, + { + "from": "21:11", + "to": "21:40", + "interval": [30] + } + ], + [] + ], + "vacation": [ + [], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + } + ], + [ + { + "from": "07:30", + "to": "10:00", + "interval": [30] + }, + { + "from": "10:01", + "to": "11:10", + "interval": [35] + }, + { + "from": "11:11", + "to": "14:10", + "interval": [30] + }, + { + "from": "14:11", + "to": "17:10", + "interval": [45] + }, + { + "from": "17:11", + "to": "19:40", + "interval": [30] + } + ], + [] + ] + } + }, + { + "id": "l", + "name": "L", + "color": "#3087D8", + "color2": "#A5C9EB", + "color2dark": "#2C4F6F", + "stops": ["OTH", "BG-MRT", "CG", "OTH"], + "notableStops": ["BG-MRT"], + "schedule": { + "term": [ + [], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [] + ], + "vacation": [ + [], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [ + { + "from": "17:00", + "to": "19:30", + "interval": [15] + } + ], + [] + ] + } + } +] diff --git a/website/src/data/isb-stops.json b/website/src/data/isb-stops.json new file mode 100644 index 0000000000..1e9463cc74 --- /dev/null +++ b/website/src/data/isb-stops.json @@ -0,0 +1,1014 @@ +[ + { + "caption": "Oei Tiong Ham Building", + "name": "OTH", + "LongName": "Oei Tiong Ham Building", + "ShortName": "OTH Bldg", + "latitude": 1.3198573188135356, + "longitude": 103.81783962249756, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "L", + "routeid": 90288 + }, + { + "name": "L", + "routeid": 90288 + } + ], + "opposite": null + }, + { + "caption": "Botanic Gardens MRT", + "name": "BG-MRT", + "LongName": "Botanic Gardens MRT", + "ShortName": "BG MRT", + "latitude": 1.3226836154536714, + "longitude": 103.81625175476076, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "L", + "routeid": 90288 + } + ], + "opposite": "CG" + }, + { + "caption": "Kent Ridge MRT", + "name": "KR-MRT", + "LongName": "Kent Ridge MRT", + "ShortName": "KR MRT", + "latitude": 1.2948847442264835, + "longitude": 103.78442566841842, + "shuttles": [ + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "PUB:95" + } + ], + "leftLabel": true, + "collapse": 16, + "collapseBehavior": "interchange", + "collapsePair": "KR-MRT-OPP", + "opposite": "KR-MRT-OPP" + }, + { + "caption": "LT 27", + "name": "LT27", + "LongName": "LT 27", + "ShortName": "LT 27", + "latitude": 1.297437218656418, + "longitude": 103.78099948167802, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + + "collapse": 15.75, + "collapseBehavior": "interchange", + "collapsePair": "S17", + "opposite": "S17" + }, + { + "caption": "University Hall", + "name": "UHALL", + "LongName": "University Hall", + "ShortName": "UHall", + "latitude": 1.2972736458482568, + "longitude": 103.77864047884944, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + + "collapse": 16, + "collapseBehavior": "interchange", + "collapsePair": "UHALL-OPP", + "opposite": "UHALL-OPP" + }, + { + "caption": "Opp University Health Centre", + "name": "UHC-OPP", + "LongName": "Opp University Health Centre", + "ShortName": "Opp UHC", + "latitude": 1.2988403243179818, + "longitude": 103.77559281885625, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + "leftLabel": true, + "collapse": 16.75, + "collapseBehavior": "hide", + "collapsePair": "UHC", + "opposite": "UHC" + }, + { + "caption": "University Town", + "name": "UTOWN", + "LongName": "University Town", + "ShortName": "UTown", + "latitude": 1.3035795710495477, + "longitude": 103.77453703433275, + "shuttles": [ + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "E", + "routeid": 90296 + }, + { + "name": "E", + "routeid": 90296 + } + ], + "opposite": null + }, + { + "caption": "Raffles Hall", + "name": "RAFFLES", + "LongName": "Raffles Hall", + "ShortName": "Raffles Hall", + "latitude": 1.301008667392403, + "longitude": 103.77260483801366, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "E", + "routeid": 90296 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:96" + } + ], + "collapseLabel": 15.75, + "leftLabel": true, + "opposite": "MUSEUM" + }, + { + "caption": "Kent Vale", + "name": "KV", + "LongName": "Kent Vale", + "ShortName": "Kent Vale", + "latitude": 1.3019109978924954, + "longitude": 103.76963295042516, + "shuttles": [ + { + "name": "K", + "routeid": 90297 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "E", + "routeid": 90296 + } + ], + "leftLabel": true, + "opposite": null + }, + { + "caption": "Museum", + "name": "MUSEUM", + "LongName": "Museum", + "ShortName": "Museum", + "latitude": 1.301132017197494, + "longitude": 103.77342123538257, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "PUB:151" + } + ], + "opposite": "RAFFLES" + }, + { + "caption": "Yusof Ishak House", + "name": "YIH", + "LongName": "Yusof Ishak House", + "ShortName": "YIH", + "latitude": 1.2988865805353285, + "longitude": 103.77428121864796, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:95" + } + ], + "collapse": 16, + "collapseBehavior": "interchange", + "collapsePair": "YIH-OPP", + "opposite": "YIH-OPP" + }, + { + "caption": "Central Library", + "name": "CLB", + "LongName": "Central Library", + "ShortName": "CLB", + "latitude": 1.2963491911313108, + "longitude": 103.77220485359432, + "shuttles": [ + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:95" + } + ], + "opposite": "IT" + }, + { + "caption": "LT 13", + "name": "LT13", + "LongName": "LT 13", + "ShortName": "LT 13", + "latitude": 1.2947905556920793, + "longitude": 103.77054959535602, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "BTC", + "routeid": 90292 + } + ], + "collapse": 15, + "collapseBehavior": "interchange", + "collapsePair": "LT13-OPP", + "opposite": "LT13-OPP" + }, + { + "caption": "AS 5", + "name": "AS5", + "LongName": "AS 5", + "ShortName": "AS 5", + "latitude": 1.2934739269872173, + "longitude": 103.77176061272623, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "BTC", + "routeid": 90292 + } + ], + "leftLabel": true, + "collapse": 15.75, + "collapseBehavior": "interchange", + "collapsePair": "NUSS-OPP", + "opposite": "NUSS-OPP" + }, + { + "caption": "BIZ 2", + "name": "BIZ2", + "LongName": "BIZ 2", + "ShortName": "BIZ 2", + "latitude": 1.2932436509698975, + "longitude": 103.77514354884626, + "shuttles": [ + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "BTC", + "routeid": 90292 + } + ], + "leftLabel": true, + "opposite": "HSSML-OPP" + }, + { + "caption": "Prince George's Park", + "name": "PGP", + "LongName": "Prince George's Park", + "ShortName": "PGP", + "latitude": 1.291766129826178, + "longitude": 103.78036145120859, + "shuttles": [ + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "K", + "routeid": 90297 + } + ], + "leftLabel": true, + "opposite": "PGPR" + }, + { + "caption": "College Green", + "name": "CG", + "LongName": "College Green", + "ShortName": "College Gr", + "latitude": 1.3232413662131859, + "longitude": 103.81638050079347, + "shuttles": [ + { + "name": "BTC", + "routeid": 90292 + }, + { + "name": "L", + "routeid": 90288 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:153" + }, + { + "name": "PUB:154" + }, + { + "name": "PUB:156" + }, + { + "name": "PUB:170" + }, + { + "name": "PUB:186" + }, + { + "name": "PUB:48" + }, + { + "name": "PUB:67" + } + ], + "opposite": "BG-MRT" + }, + { + "caption": "EA", + "name": "EA", + "LongName": "EA", + "ShortName": "EA", + "latitude": 1.300698281714852, + "longitude": 103.77012077718973, + "shuttles": [ + { + "name": "E", + "routeid": 90296 + }, + { + "name": "PUB:183" + }, + { + "name": "PUB:188" + }, + { + "name": "PUB:33" + }, + { + "name": "PUB:96" + } + ], + "collapse": 16, + "collapseBehavior": "interchange", + "collapsePair": "JP-SCH-16151", + "opposite": "JP-SCH-16151" + }, + { + "caption": "SDE 3", + "name": "SDE3", + "LongName": "SDE 3", + "ShortName": "SDE 3", + "latitude": 1.2978737200173858, + "longitude": 103.76983042806388, + "shuttles": [ + { + "name": "E", + "routeid": 90296 + }, + { + "name": "PUB:183" + }, + { + "name": "PUB:188" + }, + { + "name": "PUB:33" + }, + { + "name": "PUB:96" + } + ], + "collapse": 16, + "collapseBehavior": "interchange", + "leftLabel": false, + "collapsePair": "SDE3-OPP", + "opposite": "SDE3-OPP" + }, + { + "caption": "Information Technology", + "name": "IT", + "LongName": "Information Technology", + "ShortName": "IT", + "latitude": 1.2972656012836414, + "longitude": 103.77279158681633, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "E", + "routeid": 90296 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:95" + }, + { + "name": "PUB:96" + } + ], + "leftLabel": true, + "opposite": "CLB" + }, + { + "caption": "Opp Yusof Ishak House", + "name": "YIH-OPP", + "LongName": "Opp Yusof Ishak House", + "ShortName": "Opp YIH", + "latitude": 1.2988195425389106, + "longitude": 103.7741430848837, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "E", + "routeid": 90296 + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:95" + }, + { + "name": "PUB:96" + } + ], + "leftLabel": true, + "collapse": 16, + "collapseBehavior": "hide", + "collapsePair": "YIH", + "opposite": "YIH" + }, + { + "caption": "Opp SDE 3", + "name": "SDE3-OPP", + "LongName": "Opp SDE 3", + "ShortName": "Opp SDE 3", + "latitude": 1.2978676027978284, + "longitude": 103.76966916024686, + "shuttles": [ + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:183" + }, + { + "name": "PUB:188" + }, + { + "name": "PUB:33" + } + ], + "collapse": 16, + "collapseBehavior": "hide", + "leftLabel": true, + "collapsePair": "SDE3", + "opposite": "SDE3" + }, + { + "caption": "The Japanese Primary School", + "name": "JP-SCH-16151", + "LongName": "The Japanese Primary School", + "ShortName": "Jpn Pr Sch", + "latitude": 1.30074118600086, + "longitude": 103.77000007778408, + "shuttles": [ + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:183" + }, + { + "name": "PUB:188" + }, + { + "name": "PUB:33" + } + ], + "collapse": 16, + "collapseBehavior": "hide", + "leftLabel": true, + "collapsePair": "EA", + "opposite": "EA" + }, + { + "caption": "University Health Centre", + "name": "UHC", + "LongName": "University Health Centre", + "ShortName": "UHC", + "latitude": 1.2987940680998082, + "longitude": 103.77637267112733, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + "collapse": 16.75, + "collapseBehavior": "interchange", + "collapsePair": "UHC-OPP", + "opposite": "UHC-OPP" + }, + { + "caption": "Opp University Hall", + "name": "UHALL-OPP", + "LongName": "Opp University Hall", + "ShortName": "Opp UHall", + "latitude": 1.2975297311415932, + "longitude": 103.77793505787851, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + "leftLabel": true, + "collapse": 16, + "collapseBehavior": "hide", + "collapsePair": "UHALL", + "opposite": "UHALL" + }, + { + "caption": "S 17", + "name": "S17", + "LongName": "S 17", + "ShortName": "S 17", + "latitude": 1.2974912680707087, + "longitude": 103.7806844059378, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + "leftLabel": true, + "collapse": 15.75, + "collapseBehavior": "hide", + "collapsePair": "LT27", + "opposite": "LT27" + }, + { + "caption": "Opp Kent Ridge MRT", + "name": "KR-MRT-OPP", + "LongName": "Opp Kent Ridge MRT", + "ShortName": "Opp KR MRT", + "latitude": 1.2949484304221306, + "longitude": 103.78454837948085, + "shuttles": [ + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "K", + "routeid": 90297 + }, + { + "name": "PUB:95" + } + ], + "collapse": 16, + "collapseBehavior": "hide", + "collapsePair": "KR-MRT", + "opposite": "KR-MRT" + }, + { + "caption": "Prince George's Park Foyer", + "name": "PGPR", + "LongName": "Prince George's Park Foyer", + "ShortName": "PGP Foyer", + "latitude": 1.291009603808107, + "longitude": 103.78104776144029, + "shuttles": [ + { + "name": "D2", + "routeid": 90295 + }, + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "K", + "routeid": 90297 + } + ], + "opposite": "PGP" + }, + { + "caption": "COM 3", + "name": "COM3", + "LongName": "COM 3", + "ShortName": "COM 3", + "latitude": 1.2947986002645568, + "longitude": 103.775053024292, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "D1", + "routeid": 90294 + }, + { "name": "D2", "routeid": 90295 }, + { "name": "D2", "routeid": 90295 } + ], + "opposite": null + }, + { + "caption": "Opp HSSML", + "name": "HSSML-OPP", + "LongName": "Opp Hon Sui Sen Memorial Library", + "ShortName": "Opp HSSML", + "latitude": 1.292876281909209, + "longitude": 103.77497591078283, + "shuttles": [ + { + "name": "D1", + "routeid": 90294 + }, + { + "name": "A2", + "routeid": 90289 + } + ], + "collapse": 14, + "collapseBehavior": "hide", + "collapseLabel": 15, + "opposite": "BIZ2" + }, + { + "caption": "Opp NUSS", + "name": "NUSS-OPP", + "LongName": "Opp NUSS", + "ShortName": "Opp NUSS", + "latitude": 1.2933371691803295, + "longitude": 103.77247273921968, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D1", + "routeid": 90294 + } + ], + "collapse": 15.75, + "collapseBehavior": "hide", + "collapsePair": "AS5", + "opposite": "AS5" + }, + { + "caption": "Ventus", + "name": "LT13-OPP", + "LongName": "Ventus", + "ShortName": "Ventus", + "latitude": 1.295346804299192, + "longitude": 103.77065177075566, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "D1", + "routeid": 90294 + } + ], + "leftLabel": true, + "collapse": 15, + "collapseBehavior": "hide", + "collapsePair": "LT13", + "opposite": "LT13" + }, + { + "caption": "Kent Ridge Bus Terminal", + "name": "KRB", + "LongName": "Kent Ridge Bus Terminal", + "ShortName": "KR Bus Ter", + "latitude": 1.2942287763181035, + "longitude": 103.76979857683183, + "shuttles": [ + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "A2", + "routeid": 90289 + }, + { + "name": "A1", + "routeid": 90287 + }, + { + "name": "PUB:10" + }, + { + "name": "PUB:151" + }, + { + "name": "PUB:200" + }, + { + "name": "PUB:201" + }, + { + "name": "PUB:33" + }, + { + "name": "PUB:95" + } + ], + "leftLabel": true, + "opposite": null + }, + { + "caption": "TCOMS", + "name": "TCOMS", + "LongName": "TCOMS", + "ShortName": "TCOMS", + "latitude": 1.2937554871546668, + "longitude": 103.77687323838474, + "shuttles": [ + { + "name": "A2", + "routeid": 90289 + }, + { "name": "D2", "routeid": 90295 } + ], + "opposite": "TCOMS-OPP" + }, + { + "caption": "Opp TCOMS", + "name": "TCOMS-OPP", + "LongName": "Opp TCOMS", + "ShortName": "Opp TCOMS", + "latitude": 1.2937554871546668, + "longitude": 103.77687323838474, + "shuttles": [ + { + "name": "A1", + "routeid": 90287 + }, + { "name": "D2", "routeid": 90295 } + ], + "collapse": 16, + "collapseBehavior": "hide", + "leftLabel": true, + "collapsePair": "TCOMS", + "opposite": "TCOMS" + } +] diff --git a/website/src/data/public-bus.json b/website/src/data/public-bus.json new file mode 100644 index 0000000000..36525ef320 --- /dev/null +++ b/website/src/data/public-bus.json @@ -0,0 +1,11 @@ +{ + "10": "Tampines Int ⇄ Kent Ridge Ter", + "33": "Bedok Int ⇄ Kent Ridge Ter", + "95": "Kent Ridge Ter ⟲ Holland Village", + "96": "Clementi Int ⟲ Information Technology", + "151": "Hougang Ctrl Int ⇄ Kent Ridge Ter", + "183": "Jurong East Int ⟲ Galen", + "188": "Choa Chu Kang Int ⇄ HarbourFront Int", + "200": "Buona Vista Ter ⟲ Kent Ridge Ter", + "201": "Kent Ridge Ter ⟲ Opp Blk 41" +} diff --git a/website/src/types/buses.ts b/website/src/types/buses.ts new file mode 100644 index 0000000000..7d2f509704 --- /dev/null +++ b/website/src/types/buses.ts @@ -0,0 +1,8 @@ +export interface BusStop { + LongName: string; + ShortName: string; + caption: string; + latitude: number; + longitude: number; + name: string; +} diff --git a/website/src/utils/mobility.ts b/website/src/utils/mobility.ts new file mode 100644 index 0000000000..dfd32d554a --- /dev/null +++ b/website/src/utils/mobility.ts @@ -0,0 +1,133 @@ +import isbServicesJSON from '../data/isb-services.json'; + +const isbServices = isbServicesJSON; + +export const timeIsBefore = (a: Date, b: string) => { + const parse = (x: string) => parseInt(x, 10); + const ta = new Date(0, 0, 0, a.getHours(), a.getMinutes()); + const tb = new Date(0, 0, 0, ...b.split(':').map(parse)); + return ta < tb; +}; + +export const timeIsAfter = (a: Date, b: string) => !timeIsBefore(a, b); + +export const isWithinBlock = (time: Date, block: ScheduleBlock) => { + const { from, to } = block; + return timeIsBefore(time, to) && timeIsAfter(time, from); +}; + +export const getServiceStatus = (period: 'term' | 'vacation' = 'term') => { + const time = new Date(); + // used to debug: + // time.setHours(time.getHours() - 2); + + const serviceStatuses: ServiceStatus[] = isbServices.map((service) => { + const todaySchedule = service.schedule[period][time.getDay()]; + const currentBlock = todaySchedule.find((t) => isWithinBlock(time, t)); + if (currentBlock) { + return { + id: service.id, + running: true, + currentBlock, + }; + } + + let nextBlock; + let i = -1; + while (!nextBlock && i < 7) { + i += 1; + // if this day has schedule + if (service.schedule[period][(time.getDay() + i) % 7].length > 0) { + if (i === 0) { + // today + nextBlock = todaySchedule.find((t) => timeIsBefore(time, t.from)); + } else [nextBlock] = service.schedule[period][(time.getDay() + i) % 7]; + } + } + + // console.log(nextBlock, i, 'service:', service.id); + if (nextBlock) { + return { + id: service.id, + running: false, + runningThisPeriod: true, + nextDay: (time.getDay() + i) % 7, + nextTime: nextBlock.from, + }; + } + return { + id: service.id, + running: false, + runningThisPeriod: false, + }; + }); + return serviceStatuses; +}; + +export const getArrivalTime = (eta: number) => { + const date = new Date(); + date.setSeconds(date.getSeconds() + eta); + return date; +}; + +export const getShownArrivalTime = (eta: number, forceTime = false) => { + const date = getArrivalTime(eta); + if (!forceTime && eta < 60 * 60) { + if (eta < 60) return 'Arriving'; + return `${Math.floor(eta / 60)} mins`; + } + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + hour12: false, + // time in SGT + timeZone: 'Asia/Singapore', + }); +}; + +export const getDepartAndArriveTiming = (timings: NUSShuttle[] | string = [], isEnd: boolean) => { + if (typeof timings === 'string') return { departTiming: undefined, arriveTiming: undefined }; + + if (isEnd) { + const departTiming = timings.find((t) => t.busstopcode.endsWith('-S')); + const arriveTiming = timings.find((t) => t.busstopcode.endsWith('-E')) || timings[0]; + return { departTiming, arriveTiming }; + } + return { departTiming: timings[0], arriveTiming: undefined }; +}; + +export const getRouteSegments = (stops: string[], color: string) => { + const routes = []; + + for (let i = 0; i < stops.length - 1; i++) { + const thisStop = stops[i]; + const nextStop = stops[i + 1]; + routes.push({ + start: thisStop, + end: nextStop, + color, + }); + } + + return routes; +}; + +const btcStops = ['CG', 'OTH', 'BG-MRT']; + +export const segmentsToClasses = (segments: { start: string; end: string; color: string }[]) => + segments.map(({ start, end, color }) => { + const classes = []; + + const relationClassname = `${start}__${end}`; + const svgPrefixes = ['KRC_svg__KRC']; + if (btcStops.includes(start) || btcStops.includes(end)) { + svgPrefixes.push('BTC_svg__BTC'); + } + + classes.push(`.${svgPrefixes[0]} .${svgPrefixes[0].slice(0, -3)}${relationClassname}`); + if (svgPrefixes.length > 1) { + classes.push(`.${svgPrefixes[1]} .${svgPrefixes[1].slice(0, -3)}${relationClassname}`); + } + + return { classes, color }; + }); diff --git a/website/src/views/components/bus-map/ArrivalTimes.test.tsx b/website/src/views/components/bus-map/ArrivalTimes.test.tsx new file mode 100644 index 0000000000..6f01f21770 --- /dev/null +++ b/website/src/views/components/bus-map/ArrivalTimes.test.tsx @@ -0,0 +1,19 @@ +import { extractRoute } from './ArrivalTimes'; + +describe(extractRoute, () => { + test('should find route code', () => { + expect(extractRoute('A1')).toEqual('A1'); + expect(extractRoute('C')).toEqual('C'); + expect(extractRoute('BTC1')).toEqual('BTC1'); + }); + + test('should find route in string starting with route', () => { + expect(extractRoute('D2(to UTown)')).toEqual('D2'); + expect(extractRoute('C(to FoS)')).toEqual('C'); + }); + + test('should return null if no route code is found', () => { + expect(extractRoute('Kent Ridge MRT')).toEqual(null); + expect(extractRoute('')).toEqual(null); + }); +}); diff --git a/website/src/views/components/bus-map/ArrivalTimes.tsx b/website/src/views/components/bus-map/ArrivalTimes.tsx new file mode 100644 index 0000000000..a870552682 --- /dev/null +++ b/website/src/views/components/bus-map/ArrivalTimes.tsx @@ -0,0 +1,106 @@ +import { memo } from 'react'; +import classnames from 'classnames'; +import { entries, sortBy } from 'lodash'; + +import { RefreshCw as Refresh } from 'react-feather'; +import { BusTiming, NextBus, NextBusTime } from 'types/venues'; +import styles from '../map/BusStops.scss'; + +type Props = BusTiming & { + name: string; + code: string; + reload: (code: string) => void; +}; + +/** + * Extract the route name from the start of a string + */ +const routes = ['A1', 'A2', 'B1', 'B2', 'C', 'D1', 'D2', 'BTC1', 'BTC2']; +export function extractRoute(route: string) { + for (let i = 0; i < routes.length; i++) { + if (route.startsWith(routes[i])) return routes[i]; + } + return null; +} + +/** + * Adds 'min' to numeric timings and highlight any buses that are arriving + * soon with a tag + */ +function renderTiming(time: NextBusTime) { + if (typeof time === 'number') { + if (time <= 3) return {time} min; + return `${time} min`; + } + + if (time === 'Arr') return {time}; + return time; +} + +/** + * Route names with parenthesis in them don't have a space in front of the + * opening bracket, causing the text to wrap weirdly. This forces the opening + * paren to always have a space in front of it. + */ +function fixRouteName(name: string) { + return name.replace(/\s?\(/, ' ('); +} + +export const ArrivalTimes = memo((props: Props) => { + if (props.error) { + return ( + <> +

{props.name}

+

Error loading arrival times

+ + + ); + } + + // Make sure the routes are sorted + const timings = props.timings ? sortBy(entries(props.timings), ([route]) => route) : []; + + return ( + <> +

{props.name}

+ {props.timings && ( + + + {timings.map(([routeName, timing]: [string, NextBus]) => { + const route = extractRoute(routeName); + const className = route + ? classnames(styles.routeHeading, styles[`route${route}`]) + : ''; + + return ( + + + + + + ); + })} + +
{fixRouteName(routeName)}{renderTiming(timing.arrivalTime)}{renderTiming(timing.nextArrivalTime)}
+ )} + + + + ); +}); diff --git a/website/src/views/components/bus-map/ExpandMap.tsx b/website/src/views/components/bus-map/ExpandMap.tsx new file mode 100644 index 0000000000..d016770525 --- /dev/null +++ b/website/src/views/components/bus-map/ExpandMap.tsx @@ -0,0 +1,54 @@ +import { memo, useLayoutEffect } from 'react'; +import type { FC, PropsWithChildren } from 'react'; +import { Maximize, Minimize } from 'react-feather'; +import { useMap } from 'react-leaflet'; +import Tooltip from 'views/components/Tooltip'; +import LeafletControl from './LeafletControl'; + +type Props = { + isExpanded: boolean; + onToggleExpand: () => void; +}; + +const ExpandMap: FC> = ({ isExpanded, onToggleExpand }) => { + const map = useMap(); + + useLayoutEffect(() => { + // Leaflet maps need to have their cached size invalidated when their parent + // element resizes + map.invalidateSize(); + + // Only enable gesture handling if the map is expanded. Users expect to be able + // to pan / scroll the map when the map is the only thing on their screen. + // This is a little hacky because we are changing the behavior of the outer map + // component from inside it. Also the gestureHandling prop cannot be added to the + // outer Map component, otherwise the disable() below won't work + const { gestureHandling } = map; + if (gestureHandling) { + if (isExpanded) { + gestureHandling.disable(); + } else { + gestureHandling.enable(); + } + } + }, [isExpanded, map]); + + const label = isExpanded ? 'Minimize map' : 'Maximize map'; + + return ( + + + + + + ); +}; + +export default memo(ExpandMap); diff --git a/website/src/views/components/bus-map/ISBServices.scss b/website/src/views/components/bus-map/ISBServices.scss new file mode 100644 index 0000000000..fa86bcae9f --- /dev/null +++ b/website/src/views/components/bus-map/ISBServices.scss @@ -0,0 +1,217 @@ +@import '~styles/utils/modules-entry'; + +// Colors from https://material.io/design/color/ +$route-colors: ( + A1: #ee5050, + A2: #fcaf1b, + D1: #f48ebf, + D2: #8c56cf, + E: #16ab52, + K: #4862bd, + BTC: #fb720f, + L: #3087d8, +); + +@at-root { + body { + --map-label: #40456f; + --map-bg: #fff; + --map-deselected: #c3d7ea; + --map-dark: #40456f; + --map-light: #fff; + + &:global(.mode-dark) { + --map-label: #fff; + --map-bg: #282b3f; + --map-deselected: #384450; + } + } +} + +.stopIcon { + $size: 12px; + + position: absolute; + width: $size; + height: $size; + border-radius: 999px; + top: 9px; + left: 9px; + background: var(--map-deselected); + border: solid 1.5px var(--map-bg); + transform: scale(0.6); + transition: 0.2s ease; + + &.allRoutes { + background: #3087d8; + } + + &.interchange[data-code] { + transform: scale(0.75); + background: var(--map-light); + border: solid 2.5px var(--map-dark); + } +} + +.iconWrapper { + $color: #a1c2e1; + + .hitArea { + opacity: 0; + position: absolute; + top: 7px; + left: 7px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #3087d8; + // box-shadow: 0 0 8px rgba($color, 0.3); + transition: opacity 0.25s; + } + + &:hover .hitArea { + opacity: 0.6; + } + + .routeWrapper { + position: absolute; + top: 6px; + left: calc(100% - 8px); + // width: 100; + width: auto; + // max-width: none; + white-space: nowrap; + line-height: 1.3; + pointer-events: none; // So the entire 100 width will not be selectable when there's only one route + + &.left { + right: calc(100% - 8px); + left: auto; + text-align: right; + + .stopServicesList { + justify-content: flex-end; + } + } + + &.focused { + top: 2px; + } + } + + .stopName { + font-size: 0.8rem; + font-weight: 700; + // margin-bottom: 0.2rem; + pointer-events: auto; + color: var(--map-label); + //http://owumaro.github.io/text-stroke-generator/ + text-shadow: var(--map-bg) 1px 0 0, var(--map-bg) 0.540302px 0.841471px 0, + var(--map-bg) -0.416147px 0.909297px 0, var(--map-bg) -0.989993px 0.14112px 0, + var(--map-bg) -0.653644px -0.756803px 0, var(--map-bg) 0.283662px -0.958924px 0, + var(--map-bg) 0.96017px -0.279416px 0; + } + + .subtext { + font-size: 0.75rem; + font-weight: 700; + color: var(--gray); + text-shadow: var(--map-bg) 1px 0 0, var(--map-bg) 0.540302px 0.841471px 0, + var(--map-bg) -0.416147px 0.909297px 0, var(--map-bg) -0.989993px 0.14112px 0, + var(--map-bg) -0.653644px -0.756803px 0, var(--map-bg) 0.283662px -0.958924px 0, + var(--map-bg) 0.96017px -0.279416px 0; + } + + .focused { + font-size: 1.25rem; + } + + .stopServicesList { + display: flex; + margin-top: 0.125rem; + + .stopService { + display: block; + padding: 0.125rem 0.25rem; + background-color: var(--svc-color); + color: white; + border-radius: 0.25rem; + font-weight: 700; + font-size: 0.75rem; + margin-right: 0.25rem; + } + } + + .routeIndicatorWrapper { + display: flex; + } + + .route { + display: inline-block; + padding: 2px 3px; + border-radius: 3px; + font-weight: bold; + font-size: 9px; + line-height: 1; + pointer-events: auto; + } +} + +:global { + .leaflet-container { + background: none; + } + .leaflet-overlay-pane { + path { + vector-effect: non-scaling-stroke; + fill: none; + } + + .overlay_bg { + .KRC_svg__KRC.KRC_svg__stroke g path, + .BTC_svg__BTC.BTC_svg__stroke g path { + color: var(--map-bg); + stroke-opacity: 1; + stroke-width: 4px; + } + .KRC_svg__KRC g path, + .BTC_svg__BTC g path { + color: var(--map-deselected); + stroke-opacity: 1; + stroke-width: 2px; + } + + .allRoutes { + .KRC_svg__KRC.KRC_svg__stroke g path, + .BTC_svg__BTC.BTC_svg__stroke g path { + color: var(--map-bg); + stroke-opacity: 1; + stroke-width: 4px; + } + .KRC_svg__KRC g path, + .BTC_svg__BTC g path { + color: #3087d8; + stroke-opacity: 1; + stroke-width: 2px; + } + } + } + .overlay_fg { + .KRC_svg__KRC.KRC_svg__stroke g path, + .BTC_svg__BTC.BTC_svg__stroke g path { + color: var(--map-bg); + stroke-opacity: 0; + stroke-width: 4px; + } + .KRC_svg__KRC g path, + .BTC_svg__BTC g path { + stroke-opacity: 0; + stroke-width: 2px; + } + + .allRoutes { + display: none; + } + } + } +} diff --git a/website/src/views/components/bus-map/ISBServices.tsx b/website/src/views/components/bus-map/ISBServices.tsx new file mode 100644 index 0000000000..134db399cf --- /dev/null +++ b/website/src/views/components/bus-map/ISBServices.tsx @@ -0,0 +1,307 @@ +import { useEffect, useMemo, useState } from 'react'; +import { DivIcon, LatLngBoundsExpression } from 'leaflet'; +import { Marker, SVGOverlay, useMapEvents } from 'react-leaflet'; +import classnames from 'classnames'; + +import isbStopJson from 'data/isb-stops.json'; +import isbServicesJSON from 'data/isb-services.json'; +import { getRouteSegments, segmentsToClasses } from 'utils/mobility'; +import styles from './ISBServices.scss'; + +// these eslint warnings are disabled because we're doing some convoluted svg importing as react components :,) +// SHOULD be changed into using polyline instead, as that's the correct way to do it +// but i am but a stupid graphic designer who knows how to use illustrator and not how to use GIS software + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import KRC from './routes/KRC.svg?svgr'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import BTC from './routes/BTC.svg?svgr'; + +const BTCStops = ['CG', 'OTH', 'BG-MRT']; + +type Props = + | { + mapMode: 'all'; + onStopClicked: (stop: string | null) => void; + focusStop: string | null; + campus: 'KRC' | 'BTC'; + } + | { + mapMode: 'selected'; + selectedSegments: { + start: string; + end: string; + color: string; + }[]; + selectedStops: { + name: string; + color: string; + }[]; + onStopClicked: (stop: string | null) => void; + focusStop: string | null; + campus: 'KRC' | 'BTC'; + }; + +const KRCBounds: LatLngBoundsExpression = [ + [1.2895613206953571, 103.76316305148976], + [1.3124787799888964, 103.80900978943075], +]; +const BTCBounds: LatLngBoundsExpression = [ + [1.3059855198252486, 103.79309078105972], + [1.3289028289795464, 103.83893751900072], +]; + +const isbServices = isbServicesJSON; + +export default function ISBStops(props: Props) { + const { mapMode } = props; + const [currentZoom, setCurrentZoom] = useState(0); + const [currentFocusStop, setCurrentFocusStop] = useState(null); + + useEffect(() => { + setCurrentFocusStop(props.focusStop); + }, [props.focusStop]); + + const map = useMapEvents({ + zoom: () => { + setCurrentZoom(map.getZoom()); + }, + }); + + const selection = useMemo(() => { + let selectedSegments: { classes: string[]; color: string }[] = []; + let selectedStops: { name: string; color: string; subtext?: string }[] = []; + if (props.mapMode === 'selected') { + selectedSegments = segmentsToClasses(props.selectedSegments); + selectedStops = props.selectedStops; + } else if (props.mapMode === 'all' && currentFocusStop) { + const currentFocusStopDetails = isbStopJson.find((stop) => stop.name === currentFocusStop); + if (currentFocusStopDetails) { + const passingServices = currentFocusStopDetails.shuttles + .map((service) => { + const serviceDetails = isbServices.find((s) => s.name === service.name); + if (!serviceDetails) return null; + return serviceDetails; + }) + .filter(Boolean) as (typeof isbServices)[number][]; + passingServices.forEach((service) => { + selectedSegments = selectedSegments.concat( + segmentsToClasses(getRouteSegments(service.stops, '#3087d8')), + ); + }); + } + } + return { selectedSegments, selectedStops }; + }, [props, currentFocusStop]); + + const { selectedSegments, selectedStops } = selection; + + return ( + <> + {isbStopJson.map((stop) => { + const collapsedStop = currentZoom <= (stop?.collapse || 0); + if (mapMode === 'all' && collapsedStop && stop?.collapseBehavior === 'hide') { + return null; + } + + if ( + (props.campus === 'KRC' && BTCStops.includes(stop.name)) || + (props.campus === 'BTC' && !BTCStops.includes(stop.name)) + ) { + return null; + } + + let hasPairAndIsTheOpposite = false; + let displayType: 'normal' | 'interchange' | 'no-label' | 'hidden' = 'normal'; + let subtext = ''; + switch (mapMode) { + case 'all': + if (collapsedStop && stop?.collapseBehavior === 'hide') { + displayType = 'hidden'; + } else if (collapsedStop && stop?.collapseBehavior === 'interchange') { + displayType = 'interchange'; + } + if (stop?.collapseLabel && currentZoom <= stop.collapseLabel) { + displayType = 'no-label'; + } + break; + case 'selected': + if (!selectedStops.map((stops) => stops.name).includes(stop.name)) { + displayType = 'hidden'; + } else { + subtext = selectedStops.find((stops) => stops.name === stop.name)?.subtext || ''; + if ( + selectedStops.map((stops) => stops.name).includes(stop.name) && + selectedStops.map((stops) => stops.name).includes(stop.collapsePair || '') + ) { + if (collapsedStop && stop?.collapseBehavior === 'hide') { + displayType = 'hidden'; + } else if (collapsedStop && stop?.collapseBehavior === 'interchange') { + displayType = 'interchange'; + } + } else if ( + stop.collapsePair && // has a pair + stop.collapseBehavior === 'hide' // pair is the other pair + ) { + hasPairAndIsTheOpposite = true; + } + } + break; + default: + break; + } + + if (displayType === 'hidden') { + return null; + } + + // The hit area is an invisible circle that covers the original + // OSM bus stop so that it is clickable + const hitAreaClass = classnames(styles.hitArea); + + const stopIconClass = classnames( + styles.stopIcon, + displayType === 'interchange' && styles.interchange, + mapMode === 'all' && styles.allRoutes, + ); + + const isLeft = hasPairAndIsTheOpposite ? !stop.leftLabel : stop.leftLabel; + const isFocused = currentFocusStop === stop.name; + + // Routes are displayed to the left or right of the hit area + const routeWrapperClass = classnames( + styles.routeWrapper, + isLeft && styles.left, + isFocused && styles.focused, + ); + + const routeNameClass = classnames(styles.stopName); + + const subtextHTML = subtext ? `
${subtext}
` : ''; + + const dedupedShuttles = stop.shuttles.filter( + (service, i, arr) => arr.findIndex((s) => s.name === service.name) === i, + ); + + const services = dedupedShuttles + .map((service) => { + const serviceDetails = isbServices.find((s) => s.name === service.name); + if (!serviceDetails) return null; + return serviceDetails; + }) + .filter(Boolean) as (typeof isbServices)[number][]; + const servicesHTML = + mapMode === 'all' && isFocused + ? `
${services + .map((service) => { + const serviceDetails = isbServices.find((s) => s.name === service.name); + if (!serviceDetails) return null; + return `
${serviceDetails.name}
`; + }) + .join('')}
` + : ''; + + // for the love of god if anyone knows a better way that defining RAW HTML + const icon = new DivIcon({ + // language=HTML + html: ` +
+ +
+ + ${ + displayType !== 'no-label' + ? `
+
${ + stop.ShortName + }
${subtextHTML}${servicesHTML} +
` + : '' + }`, + className: styles.iconWrapper, + iconSize: [30, 30], + }); + + return ( + { + if (props.onStopClicked) { + props.onStopClicked(stop.name); + map.flyTo([stop.latitude, stop.longitude], 16); + } + }, + }} + /> + ); + })} + + + + + {/* background (lines' stroke) */} + + + + + + + + + {/* foreground (lines fill) */} + + + + + + + + + ); +} diff --git a/website/src/views/components/bus-map/LeafletControl.tsx b/website/src/views/components/bus-map/LeafletControl.tsx new file mode 100644 index 0000000000..16f93d9549 --- /dev/null +++ b/website/src/views/components/bus-map/LeafletControl.tsx @@ -0,0 +1,31 @@ +import type { FC, PropsWithChildren } from 'react'; + +/** + * Classes used by Leaflet to position controls. + */ +const POSITION_CLASSES = { + bottomleft: 'leaflet-bottom leaflet-left', + bottomright: 'leaflet-bottom leaflet-right', + topleft: 'leaflet-top leaflet-left', + topright: 'leaflet-top leaflet-right', +} as const; + +type MapCustomControlProps = { + position: keyof typeof POSITION_CLASSES; +}; + +/** + * A React-Leaflet component that renders React elements in Leaflet's control pane. + * + * See: https://github.com/LiveBy/react-leaflet-control/issues/44#issuecomment-723469330 + */ +const LeafletControl: FC> = ({ + position = 'topleft', + children, +}) => ( +
+
{children}
+
+); + +export default LeafletControl; diff --git a/website/src/views/components/bus-map/LocationMap.scss b/website/src/views/components/bus-map/LocationMap.scss new file mode 100644 index 0000000000..8ecf6f0424 --- /dev/null +++ b/website/src/views/components/bus-map/LocationMap.scss @@ -0,0 +1,63 @@ +@import '~styles/utils/modules-entry'; + +.debug { + position: fixed; + top: 0; + left: 0; + z-index: 99999999; + padding: 10px; + color: black; + background: white; +} + +.location { + @include media-breakpoint-down(sm) { + margin: 0 $grid-gutter-width * 0.5; + } +} + +.map { + height: 100%; +} + +.gmapBtn { + position: absolute; + top: $leaflet-control-offset; + right: $leaflet-control-offset; + z-index: 180; +} + +.mapWrapper { + position: relative; + // height: 30rem; + + &.expanded { + position: fixed; + top: $navbar-height; + right: 0; + bottom: 0; + left: 0; + z-index: $venue-detail-expanded-z-index; + height: auto; + margin: 0; + + @include media-breakpoint-down(sm) { + top: 0; + } + } +} + +.mapTile { + // filter: contrast(70%) brightness(120%) saturate(120%); + filter: contrast(35%) brightness(150%) saturate(0%); +} + +:global(.mode-dark) .mapTile { + filter: invert(1) contrast(35%) brightness(50%) saturate(0%); +} +.covidZoneLabel { + font-weight: bold; + font-size: 1.6rem; + text-shadow: 0 0 2px #000, 0 0 2px #000, 0 0 2px #000; + color: #fff; +} diff --git a/website/src/views/components/bus-map/LocationMap.tsx b/website/src/views/components/bus-map/LocationMap.tsx new file mode 100644 index 0000000000..66e105d9aa --- /dev/null +++ b/website/src/views/components/bus-map/LocationMap.tsx @@ -0,0 +1,205 @@ +import { FC, memo, useEffect } from 'react'; +import { LatLngBoundsLiteral, LatLngExpression, Map } from 'leaflet'; +import { MapContainer, TileLayer, useMap } from 'react-leaflet'; +import { GestureHandling } from 'leaflet-gesture-handling'; +import classnames from 'classnames'; +import type { LatLngTuple } from 'types/venues'; + +import isbStopsJson from 'data/isb-stops.json'; + +import styles from './LocationMap.scss'; +import ISBServices from './ISBServices'; + +const ViewingBounds = { + KRC: { + coordinates: [ + [1.2846, 103.7908], + [1.3061, 103.7633], + ] as LatLngBoundsLiteral, + minZoom: 15, + centerpoint: [ + [1.2907, 103.7854], + [1.3021, 103.7691], + ] as LatLngBoundsLiteral, + }, + BTC: { + coordinates: [ + [1.3249, 103.8122], + [1.3164, 103.8226], + ] as LatLngBoundsLiteral, + minZoom: 16.5, + centerpoint: [ + [1.31874, 103.82009], + [1.32358, 103.81405], + ] as LatLngBoundsLiteral, + }, +}; + +const isbStops = isbStopsJson; + +type Props = { + readonly position: LatLngTuple; + readonly className?: string; + readonly height?: string; + readonly zoom?: number; + readonly onStopClicked: (stop: string | null) => void; + readonly campus: 'KRC' | 'BTC'; + readonly focusStop: string | null; + readonly setFocusStop: (stop: string | null) => void; + readonly selectedSegments?: { + start: string; + end: string; + color: string; + }[]; + readonly selectedStops?: { + name: string; + color: string; + subtext?: string; + }[]; + readonly children?: React.ReactNode; +}; + +Map.addInitHook('addHandler', 'gestureHandling', GestureHandling); + +// temporary component for debugging/developing is at the bottom of the file + +function MapFocusSetter({ focusStop }: { focusStop: string | null }) { + const map = useMap(); + useEffect(() => { + if (focusStop) { + const stop = isbStops.find((s) => s.name === focusStop); + if (stop) { + map.flyTo([stop.latitude, stop.longitude], 18, { + duration: 0.5, + easeLinearity: 0.1, + }); + } + } + }, [focusStop, map]); + return null; +} + +function MapBoundsSetter({ campus }: { campus: 'KRC' | 'BTC' }) { + const map = useMap(); + const { coordinates, minZoom } = ViewingBounds[campus]; + + useEffect(() => { + map.setMaxBounds(coordinates); + map.setMinZoom(minZoom); + const centerbounds = ViewingBounds[campus].centerpoint; + const centerpoint: LatLngExpression = [ + (centerbounds[0][0] + centerbounds[1][0]) / 2, + (centerbounds[0][1] + centerbounds[1][1]) / 2, + ]; + map.setView(centerpoint, minZoom, { + animate: false, + }); + }, [campus, map, minZoom, coordinates]); + return null; +} + +const LocationMap: FC = ({ + position, + className, + zoom, + onStopClicked, + campus, + focusStop, + setFocusStop, + selectedSegments, + selectedStops, + children, +}) => { + const hasSelection = selectedSegments || selectedStops; + + return ( +
+ + + + + + + {hasSelection ? ( + { + onStopClicked(s); + setFocusStop(s); + }} + focusStop={focusStop} + campus={campus} + /> + ) : ( + { + onStopClicked(s); + setFocusStop(s); + }} + focusStop={focusStop} + campus={campus} + /> + )} + + {children} +
+ ); +}; + +export default memo(LocationMap); + +// DEBUG/DEV FUNCTIONS + +// function LocationMarker() { +// const [position, setPosition] = useState(null); +// const map = useMapEvents({ +// click(e) { +// console.log(e.latlng); +// setPosition(e.latlng); +// // copy to clipboard +// const el = document.createElement('textarea'); +// el.value = `"latitude": ${e.latlng.lat},"longitude": ${e.latlng.lng},`; +// document.body.appendChild(el); +// el.select(); +// document.execCommand('copy'); +// map.flyTo(e.latlng, map.getZoom()); +// }, +// }); + +// return position === null ? null : ( +// +// . +// +// ); +// } + +// function Debug() { +// // const map = useMap(); +// const [currentZoom, setCurrentZoom] = useState(0); + +// const map = useMapEvents({ +// zoom: () => { +// console.log('zoom', map.getZoom()); +// setCurrentZoom(map.getZoom()); +// }, +// }); +// // return null; +// return
{`zoom: ${currentZoom}`}
; +// } diff --git a/website/src/views/components/bus-map/MapContext.ts b/website/src/views/components/bus-map/MapContext.ts new file mode 100644 index 0000000000..32859896cb --- /dev/null +++ b/website/src/views/components/bus-map/MapContext.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; + +type MapContextValue = Readonly<{ + toggleMapExpanded: (boolean: boolean) => void; +}>; + +const defaultValue: MapContextValue = { + // Allows any parent component to listen to when the map component is expanded. + // This is useful for toggling styles in the parent which may interfere with + // map expanding. + toggleMapExpanded: () => { + // do nothing if not set + }, +}; + +const MapContext = createContext(defaultValue); + +export default MapContext; diff --git a/website/src/views/components/bus-map/MapViewportChanger.tsx b/website/src/views/components/bus-map/MapViewportChanger.tsx new file mode 100644 index 0000000000..cc4bf108be --- /dev/null +++ b/website/src/views/components/bus-map/MapViewportChanger.tsx @@ -0,0 +1,31 @@ +import { FC, useLayoutEffect } from 'react'; +import type { LatLng } from 'leaflet'; +import { useMap } from 'react-leaflet'; +import type { LatLngTuple } from 'types/venues'; + +type Props = { + center?: LatLng | LatLngTuple; +}; + +/** + * Sets Leaflet map viewport to the provided props whenever those props are + * changed. + * + * This component is necessary as changing `center` on `MapContainer` does not + * affect the map. + * + * @see https://react-leaflet.js.org/docs/api-map#mapcontainer + */ +const MapViewportChanger: FC = ({ center }) => { + const map = useMap(); + + useLayoutEffect(() => { + if (center !== undefined) { + map.flyTo(center); + } + }, [map, center]); + + return null; +}; + +export default MapViewportChanger; diff --git a/website/src/views/components/bus-map/icons.ts b/website/src/views/components/bus-map/icons.ts new file mode 100644 index 0000000000..fdc0cc0b2d --- /dev/null +++ b/website/src/views/components/bus-map/icons.ts @@ -0,0 +1,12 @@ +import { Icon } from 'leaflet'; +import marker from 'img/marker.svg?url'; +import styles from './LocationMap.scss'; + +/* eslint-disable import/prefer-default-export */ +export const markerIcon = new Icon({ + iconUrl: marker, + className: styles.marker, + // SVG is 365x560 + iconSize: [25, 38], + iconAnchor: [13, 38], +}); diff --git a/website/src/views/components/bus-map/routes/BTC.svg b/website/src/views/components/bus-map/routes/BTC.svg new file mode 100644 index 0000000000..f13f909c44 --- /dev/null +++ b/website/src/views/components/bus-map/routes/BTC.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/views/components/bus-map/routes/KRC.svg b/website/src/views/components/bus-map/routes/KRC.svg new file mode 100644 index 0000000000..295bc6514f --- /dev/null +++ b/website/src/views/components/bus-map/routes/KRC.svg @@ -0,0 +1,729 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/src/views/components/bus-map/withVenueLocations.tsx b/website/src/views/components/bus-map/withVenueLocations.tsx new file mode 100644 index 0000000000..a2fd58d01f --- /dev/null +++ b/website/src/views/components/bus-map/withVenueLocations.tsx @@ -0,0 +1,74 @@ +import { ComponentType } from 'react'; +import Loadable, { LoadingComponentProps } from 'react-loadable'; + +import { VenueLocationMap } from 'types/venues'; +import { Subtract } from 'types/utils'; +import LoadingSpinner from 'views/components/LoadingSpinner'; +import ApiError from 'views/errors/ApiError'; +import { getVenueLocations } from 'apis/github'; + +export type VenueLocations = { + readonly venueLocations: VenueLocationMap; +}; + +export type ErrorProps = { error: unknown; retry: () => void }; + +export type WithVenueLocationsOptions = { + Error?: ComponentType; + Loading?: ComponentType; +}; + +const defaultErrorComponent = ({ retry }: ErrorProps) => ; +const defaultLoadingComponent = () => ; + +/** + * Higher order component that injects venueLocations into an async loaded + * component. The component will only render when venueLocations is loaded. + * + * @param getComponent Function that returns a Promise resolving to the component + * @param Error Component shown when either the component or the data cannot be loaded + * Defaults to + * @param Loading Component shown while the data is loading + * Defaults to + */ +export default function withVenueLocations( + getComponent: () => Promise>, + { + Error = defaultErrorComponent, + Loading = defaultLoadingComponent, + }: WithVenueLocationsOptions = {}, +) { + return Loadable.Map({ + loader: { + Component: getComponent, + venueLocations: getVenueLocations, + }, + + loading: (props: LoadingComponentProps) => { + if (props.error) { + return ( + { + // Need to clear the memoized value first, otherwise the promise + // will always resolve to the same error + getVenueLocations.clear(); + props.retry(); + }} + /> + ); + } + + if (props.pastDelay) { + return ; + } + + return null; + }, + + render({ Component, venueLocations }, props: Subtract) { + const propsWithVenueLocations = { venueLocations, ...props } as Props; + return ; + }, + }); +} diff --git a/website/src/views/layout/Navtabs.test.tsx b/website/src/views/layout/Navtabs.test.tsx index 0b4673c394..375c41b448 100644 --- a/website/src/views/layout/Navtabs.test.tsx +++ b/website/src/views/layout/Navtabs.test.tsx @@ -45,6 +45,7 @@ describe(Navtabs, () => { "Courses", "CPEx", "Venues", + "Mobility", "Planner", "Settings", "Contribute", @@ -58,6 +59,7 @@ describe(Navtabs, () => { "Timetable", "Courses", "Venues", + "Mobility", "Planner", "Settings", "Contribute", @@ -77,6 +79,7 @@ describe(Navtabs, () => { "Courses", "CPEx", "Venues", + "Mobility", "Planner", "Settings", "Contribute", @@ -90,6 +93,7 @@ describe(Navtabs, () => { "Timetable", "Courses", "Venues", + "Mobility", "Planner", "Settings", "Contribute", diff --git a/website/src/views/layout/Navtabs.tsx b/website/src/views/layout/Navtabs.tsx index 183bb80481..e616c1b404 100644 --- a/website/src/views/layout/Navtabs.tsx +++ b/website/src/views/layout/Navtabs.tsx @@ -8,6 +8,7 @@ import { Clock, Heart, Map, + Navigation, Settings, Star, Target, @@ -61,6 +62,10 @@ const Navtabs: FC = () => { Venues + + + Mobility + Planner diff --git a/website/src/views/mobility/MobilityContainer/MobilityContainer.scss b/website/src/views/mobility/MobilityContainer/MobilityContainer.scss new file mode 100644 index 0000000000..ab0fe8ed77 --- /dev/null +++ b/website/src/views/mobility/MobilityContainer/MobilityContainer.scss @@ -0,0 +1,78 @@ +@import '~styles/utils/modules-entry'; + +.pageContainer { + // composes: page-container from global; + // max-width: 40rem; + // display: block; + position: relative; + z-index: 1; + min-height: calc(100vh - 6rem); + margin-top: -1rem; + background: var(--body-bg); + + .map { + overflow: hidden; + flex: 1; + min-width: min(100vw, 24rem); + + @media (max-width: 768px) { + height: 40vh; + min-height: 20rem; + max-height: 32rem; + } + + @media (min-width: 768px) { + position: sticky; + top: 3rem; + height: calc(100vh - 3rem); + } + } + + .container { + overflow-y: scroll; + flex: 1; + min-width: 16rem; + max-height: 100%; + padding: 1rem 1.5rem; + + @media (min-width: 768px) { + max-width: 20rem; + } + } + + @media (min-width: 768px) { + position: fixed; + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: stretch; + width: calc((100vw - 4rem)); + height: calc(100vh - 3rem); + min-height: calc(100vh - 3rem); + padding-left: 15px; + } + + @media (min-width: 1200px) { + width: calc(100vw - 10rem); + } +} + +.backButton { + padding: 0; + margin-left: -0.4rem; +} + +.switchCampusButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 99999999; +} + +@media (min-width: 768px) { + :root { + footer { + display: none; + } + } +} diff --git a/website/src/views/mobility/MobilityContainer/MobilityContainer.tsx b/website/src/views/mobility/MobilityContainer/MobilityContainer.tsx new file mode 100644 index 0000000000..2c2e7af080 --- /dev/null +++ b/website/src/views/mobility/MobilityContainer/MobilityContainer.tsx @@ -0,0 +1,180 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import classnames from 'classnames'; +import { Link, useRouteMatch, match as Match, useHistory } from 'react-router-dom'; + +import { ArrowDownLeft, ArrowUpRight, ChevronLeft } from 'react-feather'; +import useScrollToTop from 'views/hooks/useScrollToTop'; + +import LocationMap from 'views/components/bus-map/LocationMap'; +import { getRouteSegments, getServiceStatus } from 'utils/mobility'; +import NUSModerator from 'nusmoderator'; +import styles from './MobilityContainer.scss'; +import ServiceDetails from '../ServiceDetails'; +import ServiceList from '../ServiceList'; +import StopDetails from '../StopDetails'; + +import isbServicesJSON from '../../../data/isb-services.json'; + +const isbServices = isbServicesJSON; + +type Params = { + type: 'service' | 'stop'; + slug: string; +}; + +const getPropsFromMatch = (match: Match) => ({ + type: match.params.type, + slug: match.params.slug, +}); + +const BTCStops = ['CG', 'OTH', 'BG-MRT']; + +const MobilityContainer = () => { + useScrollToTop(); + + const history = useHistory(); + const match = useRouteMatch(); + const { type, slug } = getPropsFromMatch(match); + const [focusStop, setFocusStop] = useState(null); + const [campus, setCampus] = useState<'KRC' | 'BTC'>('KRC'); + const [selectedService, setSelectedService] = useState(null); + const [serviceStatus, setServiceStatus] = useState([]); + const acadWeekInfo = NUSModerator.academicCalendar.getAcadWeekInfo(new Date()); + + const busPeriod = useMemo( + () => + acadWeekInfo.sem === 'Semester 1' || acadWeekInfo.sem === 'Semester 2' ? 'term' : 'vacation', + [acadWeekInfo.sem], + ); + + const setFocusStopAndCampus = useCallback( + (stop: string) => { + if (campus === 'KRC' && BTCStops.includes(stop)) { + setCampus('BTC'); + } else if (campus === 'BTC' && !BTCStops.includes(stop)) { + setCampus('KRC'); + } + // add a little delay so the map actually animates to the stop + setTimeout(() => setFocusStop(stop), 100); + }, + [campus, setCampus, setFocusStop], + ); + + useEffect(() => { + setServiceStatus(getServiceStatus(busPeriod)); + const interval = setInterval(() => { + setServiceStatus(getServiceStatus(busPeriod)); + }, 1000 * 60 * 0.25); + return () => clearInterval(interval); + }, [busPeriod]); + + useEffect(() => { + window.scrollTo(0, 0); + if (type === 'stop') { + setFocusStopAndCampus(slug); + } else if (type === 'service') { + const service = isbServices.find((s) => s.id === slug); + if (service) { + setSelectedService(service); + setFocusStopAndCampus(service.stops[0]); + } + } else if (type === undefined) { + setSelectedService(null); + setFocusStop(null); + } + }, [type, slug, setFocusStopAndCampus]); + + return ( + <> +
+ { + if (!stop) return; + + // redirect to /stop/:stop with react router + history.push(`/mobility/stop/${stop}`); + }} + campus={campus} + focusStop={focusStop} + setFocusStop={setFocusStop} + {...(selectedService && { + selectedSegments: getRouteSegments(selectedService.stops, selectedService.color), + selectedStops: selectedService.stops.map((stop) => { + const circular = + selectedService.stops[0] === stop && + selectedService.stops[selectedService.stops.length - 1] === stop; + let subtext; + if (circular) { + subtext = 'Start/End'; + } else if (selectedService.stops[0] === stop) { + subtext = 'Start'; + } else if (selectedService.stops[selectedService.stops.length - 1] === stop) { + subtext = 'End'; + } + return { + name: stop, + color: selectedService.color, + subtext, + }; + }), + })} + > + + +
+ {type && ( + + Back + + )} + {type === undefined && } + {type === 'service' && } + {type === 'stop' && } +
+
+ + ); +}; + +export default MobilityContainer; + +function CampusToggleButton({ + campus, + setCampus, + type, +}: { + campus: 'KRC' | 'BTC'; + setCampus: (c: 'KRC' | 'BTC') => void; + type?: 'service' | 'stop'; +}) { + if (type === 'stop' || type === 'service') return null; + + return ( + + ); +} diff --git a/website/src/views/mobility/MobilityContainer/index.tsx b/website/src/views/mobility/MobilityContainer/index.tsx new file mode 100644 index 0000000000..a191be0727 --- /dev/null +++ b/website/src/views/mobility/MobilityContainer/index.tsx @@ -0,0 +1,27 @@ +import Loadable, { LoadingComponentProps } from 'react-loadable'; + +import LoadingSpinner from 'views/components/LoadingSpinner'; +import ApiError from 'views/errors/ApiError'; +import retryImport from 'utils/retryImport'; + +const AsyncContributeContainer = Loadable({ + loader: () => + retryImport(() => import(/* webpackChunkName: "contribute" */ './MobilityContainer')), + loading: (props: LoadingComponentProps) => { + if (props.error) { + return ; + } + + if (props.pastDelay) { + return ; + } + + return null; + }, +}); + +export default AsyncContributeContainer; + +export function preload() { + AsyncContributeContainer.preload(); +} diff --git a/website/src/views/mobility/ServiceDetails.scss b/website/src/views/mobility/ServiceDetails.scss new file mode 100644 index 0000000000..01e8ecc699 --- /dev/null +++ b/website/src/views/mobility/ServiceDetails.scss @@ -0,0 +1,220 @@ +.colorBar { + width: 6rem; + height: 0.25rem; + margin: -0.25rem 0 0; + border-radius: 999px; + background: var(--color); +} + +.tabber { + :global(.btn-group) { + // Make the btn-group children fill up all available space + display: flex; + margin-bottom: 1rem; + + :global(.btn) { + flex: 1 0 0; + padding: 0.4rem 0 0.3rem; + margin-right: 0.25rem; + border-width: 0 0 0.25rem; + border-color: var(--color); + border-radius: 0; + + &:last-child { + margin-right: 0; + } + } + + :global(.btn-primary) { + background-color: var(--color); + } + :global(.btn-outline-primary) { + color: var(--color); + } + :global(.btn-primary):hover, + :global(.btn-outline-primary):hover { + color: var(--body-bg); + background-color: var(--color); + } + :global(.btn-primary):active, + :global(.btn-outline-primary):active { + color: var(--body-bg); + background-color: var(--color); + border-color: var(--color); + } + :global(.btn-primary):focus, + :global(.btn-outline-primary):focus { + box-shadow: none; + } + } +} + +ol.stops.collapseToTabber { + // there are other pages that use the .stops class below + // and this margin is specifically for this page, + // so i separated it to another class + margin-top: -1rem; +} + +ol.stops { + overflow: hidden; + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.stop { + display: flex; + align-items: flex-start; + padding: 0.5rem 0; + position: relative; + color: var(--gray); + + &:before { + position: absolute; + top: -0.5rem; + left: 0.875rem; + width: 0.25rem; + height: 100%; + content: ''; + background: var(--color); + margin-right: 0.5rem; + margin-top: 0.25rem; + // opacity: 0.25; + z-index: 10; + } + + &.isFirst:before { + height: 50%; + top: 50%; + } + + &.isLast:before { + height: 50%; + } + + .stopSymbol { + width: 1rem; + height: 1rem; + margin: 0.375rem 0.5rem 0; + margin-right: 1rem; + border-radius: 999px; + background: var(--color); + border: 0.2rem solid var(--body-bg); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + position: relative; + z-index: 20; + transition: 0.1s ease; + } + + &.isTerminal .stopSymbol, + .stopSymbol.isTerminal { + border-radius: 0.125rem; + transform: rotate(45deg); + } + + &:hover { + color: var(--gray-dark); + + .stopSymbol { + // border-color: var(--color); + // background: var(--color); + transform: rotate(45deg) scale(1.5); + } + } +} + +.schedule { + // border: 1px solid var(--gray-lighter); + background: var(--gray-lightest); + border-radius: 0.25rem; + margin: 0.75rem 0; + // border-left: 0.25rem solid var(--color); + + overflow: hidden; + position: relative; + + .scheduleHeader { + display: flex; + align-items: center; + padding: 0.625rem 1rem; + + .scheduleTitle { + flex: 1; + font-weight: 700; + font-size: 1rem; + margin: 0; + } + + .scheduleChevron, + .scheduleChevron svg { + width: 1.25rem; + height: 1.25rem; + } + } + .scheduleDetails { + padding: 0.625rem 1rem; + + tbody { + font-size: 0.9rem; + } + + tr { + display: flex; + align-items: flex-start; + color: var(--body-color); + + &:first-child td { + border: none; + } + &.now { + font-weight: 700; + color: var(--gray-dark); + } + } + + td { + border-color: var(--gray-lighter); + + &.time { + width: 3rem; + text-align: center; + font-feature-settings: 'tnum'; + } + + &.timeSep { + width: 1rem; + text-align: center; + } + + &.freq { + flex: 1; + text-align: right; + // font-feature-settings: 'tnum'; + display: flex; + justify-content: flex-end; + align-items: baseline; + + .freqMins { + width: 1.25rem; + text-align: center; + } + .freqMinsSep { + width: 0.75rem; + text-align: center; + } + + .freqMinsLabel { + margin-left: 0.25rem; + font-size: 0.75rem; + } + } + } + } +} diff --git a/website/src/views/mobility/ServiceDetails.tsx b/website/src/views/mobility/ServiceDetails.tsx new file mode 100644 index 0000000000..d84aed7cde --- /dev/null +++ b/website/src/views/mobility/ServiceDetails.tsx @@ -0,0 +1,160 @@ +import Title from 'views/components/Title'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import ButtonGroupSelector from 'views/components/ButtonGroupSelector'; +import { useMemo, useState } from 'react'; +import { ChevronDown, ChevronUp } from 'react-feather'; +import { isWithinBlock } from 'utils/mobility'; +import isbServicesJSON from '../../data/isb-services.json'; +import isbStopsJSON from '../../data/isb-stops.json'; + +import styles from './ServiceDetails.scss'; + +const isbServices = isbServicesJSON; +const isbStops = isbStopsJSON; + +type Props = { + service: string; +}; + +function ServiceSchedule({ + schedule, + title, + defaultExpand, +}: { + schedule: ScheduleBlock[][]; + title: string; + defaultExpand: boolean; +}) { + const [expanded, setExpanded] = useState(defaultExpand); + const currentDay = new Date().getDay() >= 1 && new Date().getDay() <= 5 ? 1 : new Date().getDay(); + const [selectedDay, setSelectedDay] = useState(currentDay); + const scheduleForDay = useMemo(() => schedule[selectedDay], [schedule, selectedDay]); + + return ( +
+
setExpanded((e) => !e)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') setExpanded((ex) => !ex); + }} + > +

{title}

+ + {expanded ? ( + + ) : ( + + )} +
+ {expanded && ( +
+
+ +
+ + + {scheduleForDay.map((block) => { + const isNow = selectedDay === currentDay && isWithinBlock(new Date(), block); + return ( + + + + + + + ); + })} + +
{block.from}{block.to} +
{block.interval[0]}
+ {block.interval.length > 1 && ( + <> +
+
{block.interval[1]}
+ + )} +
mins
+
+
+ )} +
+ ); +} + +function ServiceDetails(props: Props) { + const [selectedTab, setSelectedTab] = useState<'Stops' | 'Schedule'>('Stops'); + const serviceDetails = useMemo(() => isbServices.find((s) => s.id === props.service), [props]); + if (!serviceDetails) return
Service not found
; + + const { name, color, id } = serviceDetails; + const stops = serviceDetails.stops + .map((stop) => isbStops.find((s) => s.name === stop) || null) + .filter((stop) => stop !== null) as ISBStop[]; + + return ( +
+ {`ISB Service ${name}`} +

Service {name}

+
+ setSelectedTab(selected as 'Stops' | 'Schedule')} + /> +
+ {selectedTab === 'Stops' && ( +
    + {stops.map((stop, i) => ( +
  1. + + + {stop?.LongName} + +
  2. + ))} +
+ )} + {selectedTab === 'Schedule' && ( + <> + + + + )} +
+ ); +} + +export default ServiceDetails; diff --git a/website/src/views/mobility/ServiceList.scss b/website/src/views/mobility/ServiceList.scss new file mode 100644 index 0000000000..879e1376e1 --- /dev/null +++ b/website/src/views/mobility/ServiceList.scss @@ -0,0 +1,108 @@ +.serviceList { + // remove all list stuff + list-style: none; + margin: 0; + padding: 0; +} + +.serviceItem { + padding: 0.75rem 0; + border-top: 1px solid var(--gray-lighter); + display: flex; + flex-direction: row; + align-items: flex-start; + cursor: pointer; + transition: background-color 0.2s ease-in-out; + + &:hover { + background-color: var(--gray-lightest); + } + + .name { + font-weight: 700; + font-size: 1rem; + background: var(--color); + color: white; + padding: 0.5rem 0; + margin: 0; + width: 2.5rem; + height: 2.5rem; + text-align: center; + border-radius: 0.25rem; + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.25rem; + } + + .description { + font-size: 1rem; + padding: 0 0.5rem; + color: var(--body-color); + margin: 0; + flex: 1; + display: flex; + flex-direction: column; + + .terminals { + margin: 0; + font-weight: 700; + line-height: 1.25; + margin-bottom: 0.25rem; + + .arrow { + margin: 0 0.25rem 0 0.3rem; + display: inline; + vertical-align: -2px; + line-height: 0; + stroke-width: 3px; + } + } + + .notable { + margin: 0; + font-size: 0.8rem; + + .stop { + font-weight: 600; + } + } + + .status { + margin: 0; + font-size: 0.75rem; + font-weight: 600; + color: var(--status-color); + display: flex; + align-items: center; + + svg.statusIcon { + margin-right: 0.25rem; + fill: var(--status-color); + stroke: var(--body-bg); + stroke-width: 0.05rem; + + line { + stroke: var(--body-bg); + stroke-width: 0.125rem; + } + } + + &.running { + --status-color: #34a387; + } + + &.paused { + --status-color: #f78d35; + } + + &.stopped { + --status-color: #eb4848; + } + } + } + + &.inactive .name { + opacity: 0.5; + } +} diff --git a/website/src/views/mobility/ServiceList.tsx b/website/src/views/mobility/ServiceList.tsx new file mode 100644 index 0000000000..5704fdfd35 --- /dev/null +++ b/website/src/views/mobility/ServiceList.tsx @@ -0,0 +1,147 @@ +import Title from 'views/components/Title'; +import { Link } from 'react-router-dom'; +import classNames from 'classnames'; +import { AlertTriangle, ArrowRight, Circle, Pause, RotateCcw } from 'react-feather'; +import isbServicesJSON from '../../data/isb-services.json'; +import isbStopsJSON from '../../data/isb-stops.json'; + +import styles from './ServiceList.scss'; + +const isbServices = isbServicesJSON; +const isbStops = isbStopsJSON; + +type Props = { + serviceStatus: ServiceStatus[]; +}; + +const daysOfWeek = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + +function StatusDisplay(props: { status?: ServiceStatus }) { + const status = props.status || { running: false, runningThisPeriod: false }; + if (status.running) { + return ( + <> + + + Every {status.currentBlock.interval.join('–')} minutes + + + ); + } + if (status.runningThisPeriod) { + return ( + <> + + + Resumes {status.nextDay === new Date().getDay() ? '' : daysOfWeek[status.nextDay]}{' '} + {status.nextTime} + + + ); + } + return ( + <> + + Vacation break + + ); +} + +function ServiceDetails({ serviceStatus }: Props) { + return ( +
+ ISB Services +

ISB Services

+
    + {isbServices.map((service) => { + const start = isbStops.find((s) => s.name === service.stops[0])?.ShortName; + const end = isbStops.find( + (s) => s.name === service.stops[service.stops.length - 1], + )?.ShortName; + const mid = isbStops.find( + (s) => s.name === service.stops[Math.floor(service.stops.length / 2)], + )?.ShortName; + let circular = false; + if (start === end) { + circular = true; + } + + const status: ServiceStatus = serviceStatus.find((s) => s.id === service.id) || { + id: '0', + running: false, + runningThisPeriod: false, + }; + + return ( +
  1. + +

    + {service.name} +

    +

    +

    + {circular ? ( + <> + {start} + + {mid} + + ) : ( + <> + {start} + + {mid} + + {end} + + )} +

    +

    + via{' '} + {service.notableStops.map((stop, i) => { + const stopDetails = isbStops.find((s) => s.name === stop); + if (!stopDetails) return null; + return ( + + {i ? ' · ' : ''} + {stopDetails.ShortName} + + ); + })} +

    +

    + +

    +

    + +
  2. + ); + })} +
+
+ ); +} + +export default ServiceDetails; diff --git a/website/src/views/mobility/StopDetails.scss b/website/src/views/mobility/StopDetails.scss new file mode 100644 index 0000000000..b6471be9c6 --- /dev/null +++ b/website/src/views/mobility/StopDetails.scss @@ -0,0 +1,303 @@ +@import '~styles/utils/modules-entry.scss'; +@import './ServiceDetails.scss'; + +.fullname { + font-size: 1.25rem; + font-weight: 400; + margin-top: -0.5rem; +} + +.stopDetails { + h1 { + margin-bottom: 0; + } +} + +.incomingBusesWrapper { + overflow: auto; + margin: 1rem 0 1.5rem; + ol.incomingBuses { + list-style: none; + margin: 0; + padding: 0; + display: flex; + max-width: none; + + .serviceWithChevron { + display: flex; + } + + .chevron { + width: 1rem; + height: 1rem; + margin: 0.5rem 0.25rem; + } + .incomingBus { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 0 0 2rem; + .busNames { + display: flex; + flex-direction: row; + gap: 0.25rem; + } + + &.noChevron { + margin-left: 0.5rem; + } + + &:not(:last-child) { + margin-right: 0.5rem; + } + + .service { + color: white; + background: var(--color); + border-radius: 0.25rem; + font-weight: 700; + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + margin-bottom: 0.25rem; + } + + .arrivingIn { + font-weight: 400; + font-size: 0.75rem; + + white-space: nowrap; + } + } + } + + .noIncoming { + flex: 1 1 0; + // text-align: center; + margin-left: 1rem; + font-size: 0.8rem; + font-style: italic; + } +} + +.stopService { + // border: 1px solid var(--gray-lighter); + background: var(--gray-lightest); + border-radius: 0.25rem; + margin: 0.75rem 0; + // border-left: 0.25rem solid var(--color); + + padding-left: 0.25rem; + overflow: hidden; + position: relative; + + &:after { + content: ''; + display: block; + background: var(--color); + height: 100%; + width: 0.25rem; + position: absolute; + left: 0; + top: 0; + z-index: 1; + } + .serviceHeader { + display: flex; + align-items: center; + padding: 0.625rem 1rem 0.625rem 1.375rem; + + .serviceName { + flex: 1; + font-weight: 700; + font-size: 1rem; + margin: 0; + } + + .serviceNextBus { + font-weight: 400; + font-size: 0.75rem; + margin-right: 0.5rem; + .serviceEnds { + font-weight: 700; + } + } + .serviceChevron, + .serviceChevron svg { + width: 1.25rem; + height: 1.25rem; + } + + .extLink, + .extLink svg { + display: flex; + width: 1.25rem; + height: 1.25rem; + } + } + + .serviceDetails { + padding: 0.5rem 0rem; + + ol.stops { + @extend .stops; + padding-left: 0rem; + margin-left: -1.125rem; + + .showMore { + color: theme-color(primary); + padding-left: 2.5rem; + font-size: 0.75rem; + margin: 0.25rem 0; + .chevron { + width: 1rem; + height: 1rem; + } + } + } + + .stop { + @extend .stop; + + &.isTerminal { + font-size: 1rem; + } + + &.passed { + font-style: italic; + --color: var(--color2); + color: var(--gray-light); + + &:before { + background-color: var(--color2); + z-index: 11; + } + + &.isTerminal:before { + // height: 100%; + } + } + &.minor { + font-size: 0.8rem; + padding: 0.25rem 0; + + .stopSymbol { + width: 0.75rem; + height: 0.75rem; + margin: 0.25rem 0.625rem 0; + margin-right: 1.125rem; + font-size: 0.75rem; + } + + &.isTerminal .stopSymbol { + width: 1rem; + height: 1rem; + margin: 0 0.5rem; + margin-right: 1rem; + } + } + + &.immediatePrev { + &:before { + height: 170%; + } + + &.passed.isFirst:before { + height: 100%; + } + } + + &.current { + font-weight: 700; + } + + .stopSymbol { + border-color: var(--gray-lightest); + } + } + + .divider { + margin: 0.75rem 0; + height: 1px; + background: var(--gray-lighter); + } + + .serviceUpcomingError { + padding: 0.25rem 1rem 0.375rem 1.375rem; + font-size: 0.8rem; + color: var(--gray-light); + } + + .serviceUpcoming { + display: flex; + gap: 0.5rem; + padding: 0.25rem 1rem 0 1.375rem; + justify-content: space-between; + + .serviceSchedule { + flex: 0 1 50%; + display: flex; + flex-direction: column; + + .header { + // flex: 1 0 2.5rem; + font-size: 1rem; + margin: 0 0 0.5rem; + // margin-right: 0.5rem; + } + + ol.upcomingBuses { + list-style: none; + margin: 0; + padding: 0; + + .upcomingBus { + display: flex; + align-items: center; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + margin-bottom: 0.1rem; + + .plate { + // have border and padding + + display: block; + font-size: 0.625rem; + font-weight: 400; + color: var(--gray); + // background: var(--color); + border: 1px solid var(--gray-lighter); + border-radius: 0.25rem; + width: 2rem; + text-align: center; + margin-left: 0.5rem; + } + } + } + } + } + + .serviceEndNotice { + padding: 0.125rem 1rem 0.125rem 1.375rem; + font-size: 0.8rem; + // font-weight: 700; + font-style: italic; + text-align: center; + } + } +} + +.publicBus .serviceDetails { + padding: 0 1rem 0.625rem 1.375rem; + font-size: 0.75rem; +} +:global(.mode-dark) .stopService .serviceDetails { + .stop.passed { + --color: var(--color2--dark); + } + .stop.passed:before { + background-color: var(--color2--dark); + } +} diff --git a/website/src/views/mobility/StopDetails.tsx b/website/src/views/mobility/StopDetails.tsx new file mode 100644 index 0000000000..18bf2fda72 --- /dev/null +++ b/website/src/views/mobility/StopDetails.tsx @@ -0,0 +1,570 @@ +/* eslint-disable no-underscore-dangle */ +import Title from 'views/components/Title'; +import { Link } from 'react-router-dom'; +import { Fragment, useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { ChevronDown, ChevronRight, ChevronUp, ExternalLink } from 'react-feather'; +import { getDepartAndArriveTiming, getShownArrivalTime } from 'utils/mobility'; +import { getStopTimings } from 'apis/nextbus-new'; +import isbServicesJSON from '../../data/isb-services.json'; +import isbStopsJSON from '../../data/isb-stops.json'; +import publicBusJSON from '../../data/public-bus.json'; + +import styles from './StopDetails.scss'; + +const isbServices = isbServicesJSON as ISBService[]; +const isbStops = isbStopsJSON as ISBStop[]; +const publicBus = publicBusJSON as Record; + +type Props = { + stop: string; + setSelectedService: (service: ISBService) => void; +}; + +function ServiceStop(props: { stop: ISBStop; className?: string }) { + const { stop } = props; + return ( + + + {stop.LongName} + + ); +} + +function ServiceSchedule(props: { timing?: NUSShuttle; title: string }) { + const { timing, title } = props; + return ( +
+

{title}

+ {timing?._etas ? ( +
    + {timing._etas.map((eta) => ( +
  1. + {getShownArrivalTime(eta.eta_s, true)} + {eta.plate.slice(-3)} +
  2. + ))} +
+ ) : ( +
No upcoming departures
+ )} +
+ ); +} + +function StopServiceDetails(props: { + service: ISBService; + timings?: NUSShuttle[] | string; + currentStop: ISBStop; + selectedService: string | null; + setSelectedService: (service: string | null) => void; +}) { + const [showPrevStops, setShowPrevStops] = useState(false); + const [showAllNextStops, setShowAllNextStops] = useState(false); + const { service, timings, currentStop, selectedService, setSelectedService } = props; + + const isStart = currentStop.name === service.stops[0]; + const isEnd = currentStop.name === service.stops[service.stops.length - 1]; + + const { departTiming, arriveTiming } = useMemo( + () => getDepartAndArriveTiming(timings, isEnd), + [timings, isEnd], + ); + + const toggleSelectedService = useMemo( + () => () => { + if (selectedService === service.name) { + setSelectedService(null); + } else { + setSelectedService(service.name); + } + }, + [selectedService, service.name, setSelectedService], + ); + + const nextBuses: string[] = useMemo(() => { + const buses: string[] = []; + if (departTiming?._etas && departTiming._etas.length > 0) { + const nextBusEta = departTiming._etas[0].eta_s; + const nextBusArrivalTime = getShownArrivalTime(nextBusEta); + buses.push(`${nextBusArrivalTime}`); + + if (departTiming._etas.length > 1) { + const secondNextBusEta = departTiming._etas[1].eta_s; + const secondNextBusArrivalTime = getShownArrivalTime(secondNextBusEta); + buses.push(`${secondNextBusArrivalTime}`); + } + } + return buses; + }, [departTiming]); + + const lineStops = useMemo( + () => + service.stops + .map((stop) => { + const stopDetails = isbStops.find((s) => s.name === stop); + if (!stopDetails) return null; + return stopDetails; + }) + .filter(Boolean) as ISBStop[], + [service.stops], + ); + + const thisStopIndex = useMemo( + () => lineStops.findIndex((stop) => stop.name === currentStop.name), + [lineStops, currentStop.name], + ); + + const { circular, termini, isTerminus } = useMemo(() => { + let c = true; + const t = [lineStops[0]]; + if (t[0].name !== lineStops[lineStops.length - 1].name) { + c = false; + t.push(lineStops[lineStops.length - 1]); + } + + const i = t.some((terminus) => terminus.name === currentStop.name); + + return { circular: c, termini: t, isTerminus: i }; + }, [lineStops, currentStop.name]); + + const adjacentStops = useMemo(() => { + const immediatePrevStops = lineStops.slice( + Math.max(0, thisStopIndex - 1), + Math.max(0, thisStopIndex), + ); + const prevStops = lineStops.slice(0, Math.max(0, thisStopIndex - 1)); + const immediateNextStops = lineStops.slice( + Math.min(thisStopIndex + 1, lineStops.length), + Math.min(thisStopIndex + 4, lineStops.length), + ); + const nextStops = lineStops + .slice(Math.min(thisStopIndex + 4, lineStops.length), lineStops.length) + .filter((stop) => termini.every((terminus) => stop.name !== terminus.name)); + + return { immediatePrevStops, prevStops, immediateNextStops, nextStops }; + }, [lineStops, thisStopIndex, termini]); + + const { immediatePrevStops, prevStops, immediateNextStops, nextStops } = adjacentStops; + + return ( +
+
{ + if (e.key === 'Enter') toggleSelectedService(); + }} + > +

{service.name}

+ + {isEnd && !isStart ? ( + No boarding + ) : ( + nextBuses.map((bus, i) => ( + + {i === 0 && nextBuses.length > 1 && nextBuses[1].includes('m') + ? bus.slice(0, -5) + : bus} + {i !== nextBuses.length - 1 && ', '} + + )) + )} + + {selectedService === service.name ? ( + + ) : ( + + )} +
+ {selectedService === service.name && ( +
+
+
    + {prevStops.length > 0 && ( + setShowPrevStops(!showPrevStops)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') setShowPrevStops(!showPrevStops); + }} + > + {showPrevStops ? ( + <> + Hide previous stops + + ) : ( + <> + {prevStops.length} previous stop{prevStops.length > 1 ? 's' : ''}{' '} + + + )} + + )} + {showPrevStops && + prevStops.map((stop, i) => ( + + ))} + {immediatePrevStops.map((stop) => ( + + ))} + + {immediateNextStops.map( + (stop) => + !termini.some((terminus) => stop.name === terminus.name) && ( + + ), + )} + {showAllNextStops && + nextStops.map( + (stop, i) => + !termini.some((terminus) => stop.name === terminus.name) && ( + + ), + )} + {nextStops.length > 0 && ( + setShowAllNextStops(!showAllNextStops)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter') setShowAllNextStops(!showAllNextStops); + }} + > + {showAllNextStops ? ( + <> + Show fewer stops + + ) : ( + <> + {nextStops.length} more stop{nextStops.length > 1 ? 's' : ''}{' '} + + + )} + + )} + {(circular || termini[1].name !== currentStop.name) && ( + + )} +
+
+ {isEnd && !isStart && ( +
+ ※ Service {service.name} terminates here
+ All passengers must alight +
+ )} +
+ {!departTiming && !arriveTiming ? ( +
+ {timings === 'error' ? <>Error fetching bus timings : <>No upcoming departures} +
+ ) : ( +
+ {arriveTiming && } + {departTiming && } +
+ )} +
+ )} +
+ ); +} + +function PublicBusDetails(props: { service: PublicShuttle }) { + const { service } = props; + const nextBuses: string[] = []; + if (service.arrivalTime) nextBuses.push(service.arrivalTime); + if (service.nextArrivalTime) nextBuses.push(service.nextArrivalTime); + return ( +
+
+

{service.number}

+ + {nextBuses.length ? `${nextBuses.join(', ')} mins` : ''} + + + + +
+ {publicBus[service.number] && ( +
+ {publicBus[service.number]} +
+ )} +
+ ); +} + +function StopDetails(props: Props) { + const { stop } = props; + const setSelectedServiceMap = props.setSelectedService; + const stopDetails = isbStops.find((s) => s.name === stop); + const [selectedStopTiming, setSelectedStopTiming] = useState< + ShuttleServiceResult | 'error' | 'loading' + >('loading'); + const [selectedService, setSelectedService] = useState(null); + + useEffect(() => { + if (!stop) return; + setSelectedStopTiming('loading'); + getStopTimings( + stop, + (data) => { + setSelectedStopTiming(data); + }, + () => { + // TODO: Surface the error. + // console.error(error); + setSelectedStopTiming('error'); + }, + ); + }, [stop]); + + useEffect(() => { + if (selectedService) { + setSelectedServiceMap(isbServices.find((s) => s.name === selectedService) || isbServices[0]); + } + }, [selectedService, setSelectedServiceMap]); + + const incoming = useMemo(() => { + if (stopDetails === undefined) return null; + + const nusShuttles = stopDetails.shuttles + .filter((shuttle) => shuttle.routeid) + .filter( + (shuttle, index, self) => index === self.findIndex((s) => s.routeid === shuttle.routeid), + ) + .sort((a, b) => a.name.localeCompare(b.name)); + + if (selectedStopTiming === 'loading' || selectedStopTiming === 'error') { + return { + services: nusShuttles, + buses: [], + buses_grouped: [], + }; + } + + const incomingBuses: { + service: ISBService; + arrivingInSeconds: number; + plate: string; + }[] = []; + nusShuttles.forEach((shuttle) => { + const serviceDetail = isbServices.find((s) => s.id === shuttle.name.toLocaleLowerCase()); + if (!serviceDetail) return; + const isEnd = stopDetails.name === serviceDetail.stops[serviceDetail.stops.length - 1]; + const serviceShuttles = selectedStopTiming?.shuttles.filter( + (s) => s.name === shuttle.name, + ) as NUSShuttle[]; + const timings = getDepartAndArriveTiming(serviceShuttles, isEnd); + const timing = timings.departTiming; + + timing?._etas?.forEach((eta) => { + incomingBuses.push({ + service: serviceDetail, + arrivingInSeconds: eta.eta_s, + plate: eta.plate, + }); + }); + }); + incomingBuses.sort((a, b) => a.arrivingInSeconds - b.arrivingInSeconds); + incomingBuses.splice(4); + const incomingBusGroups = incomingBuses.reduce((acc, bus) => { + const shownTime = getShownArrivalTime(bus.arrivingInSeconds); + const newAcc = { ...acc }; + if (!newAcc[shownTime]) newAcc[shownTime] = []; + newAcc[shownTime].push(bus); + return newAcc; + }, {} as Record); + + return { + services: nusShuttles, + buses: incomingBuses, + buses_grouped: incomingBusGroups, + }; + }, [selectedStopTiming, stopDetails]); + + const incomingPublic = useMemo(() => { + if (stopDetails === undefined) return null; + const { shuttles } = stopDetails; + + return shuttles + .filter((shuttle) => shuttle.name.startsWith('PUB:')) + .map((shuttle) => { + let st = { + number: parseInt(shuttle.name.replace('PUB:', ''), 10), + } as PublicShuttle; + if (selectedStopTiming !== 'loading' && selectedStopTiming !== 'error') { + st = { + ...selectedStopTiming?.shuttles?.find((s) => s.name === shuttle.name), + number: parseInt(shuttle.name.replace('PUB:', ''), 10), + } as PublicShuttle; + } + return st; + }) + .sort((a, b) => a.number - b.number); + }, [selectedStopTiming, stopDetails]); + + if (!stopDetails || !incoming || !incomingPublic) return
Stop not found
; + + const { ShortName, LongName } = stopDetails; + + const subtitle = []; + if (ShortName !== LongName) { + subtitle.push({LongName}); + } + + if (stopDetails.opposite) { + const oppositeStop = isbStops.find((s) => s.name === stopDetails.opposite); + const oppositeStopName = oppositeStop?.ShortName || stopDetails.opposite; + subtitle.push( + + {(oppositeStopName.startsWith('Opp') && oppositeStopName.endsWith(ShortName)) || + (ShortName.startsWith('Opp') && oppositeStopName.endsWith(ShortName.replace('Opp ', ''))) + ? '' + : 'Opp: '}{' '} + {oppositeStopName} + , + ); + } + + return ( +
+ {`${ShortName}`} +

{ShortName}

+

+ {subtitle.map((s, i) => ( + + {i > 0 && ' • '} + {s} + + ))} +

+ +
+
    + {Object.entries(incoming.buses_grouped).length ? ( + Object.entries(incoming.buses_grouped).map(([time, buses], i) => ( + + {i > 0 && } +
  1. +
    +
    + {buses.map((bus) => ( + + {bus.service?.name} + + ))} +
    + {time} +
    +
  2. +
    + )) + ) : ( + + {selectedStopTiming === 'error' + ? 'Error fetching bus timings' + : 'No upcoming buses today'} + + )} +
+
+ + {incoming.services.map((shuttle) => { + const service = isbServices.find((s) => s.id === shuttle.name.toLocaleLowerCase()); + let timings; + if (selectedStopTiming === 'loading') { + timings = 'loading'; + } else if (selectedStopTiming === 'error') { + timings = 'error'; + } else { + timings = selectedStopTiming?.shuttles.filter( + (s) => s.name === shuttle.name, + ) as NUSShuttle[]; + } + if (!service) return ; + return ( + + ); + })} + {incomingPublic.map((shuttle) => ( + + ))} +
+ ); +} + +export default StopDetails; diff --git a/website/src/views/mobility/isb.d.ts b/website/src/views/mobility/isb.d.ts new file mode 100644 index 0000000000..9aaf2e0236 --- /dev/null +++ b/website/src/views/mobility/isb.d.ts @@ -0,0 +1,109 @@ +/* eslint-disable camelcase */ + +interface ShuttleServiceResult { + TimeStamp: string; + hints: string[]; + name: string; + shuttles: Shuttle[]; + caption: string; +} + +type NUSShuttle = { + passengers: string; + name: string; + _etas?: Eta[]; + nextArrivalTime: string; + routeid: number; + // ends with -S or -E if its a terminus + busstopcode: string; + arrivalTime_veh_plate: string; + arrivalTime: string; + nextPassengers: string; + nextArrivalTime_veh_plate: string; +}; + +type PublicShuttleAPI = { + name: string; + busstopcode: string; + arrivalTime: string; + nextArrivalTime: string; +}; + +interface PublicShuttle extends PublicShuttleAPI { + number: number; +} + +type Shuttle = NUSShuttle | PublicShuttleAPI; + +interface Eta { + plate: string; + px: string; + ts: string; + jobid: number; + eta: number; + eta_s: number; +} + +interface ISBStop { + caption: string; + name: string; + LongName: string; + ShortName: string; + latitude: number; + longitude: number; + shuttles: ( + | { + name: string; + routeid: number; + } + | { + name: string; + routeid?: undefined; + } + )[]; + leftLabel?: boolean; + collapse?: number; + collapseBehavior?: string; + collapsePair?: string; + collapseLabel?: undefined; + opposite: string | null; +} + +interface ScheduleBlock { + from: string; + to: string; + interval: number[]; +} + +interface ISBService { + id: string; + name: string; + color: string; + color2: string; + color2dark: string; + stops: string[]; + notableStops: string[]; + schedule: { + term: ScheduleBlock[][]; + vacation: ScheduleBlock[][]; + }; +} + +type ServiceStatus = + | { + id: string; + running: true; + currentBlock: ScheduleBlock; + } + | { + id: string; + running: false; + runningThisPeriod: true; + nextDay: number; + nextTime: string; + } + | { + id: string; + running: false; + runningThisPeriod: false; + }; diff --git a/website/src/views/routes/Routes.tsx b/website/src/views/routes/Routes.tsx index 48c5f45d75..0595b59814 100644 --- a/website/src/views/routes/Routes.tsx +++ b/website/src/views/routes/Routes.tsx @@ -6,6 +6,7 @@ import ModulePageContainer from 'views/modules/ModulePageContainer'; import ModuleArchiveContainer from 'views/modules/ModuleArchiveContainer'; import ModuleFinderContainer from 'views/modules/ModuleFinderContainer'; import VenuesContainer from 'views/venues/VenuesContainer'; +import MobilityContainer from 'views/mobility/MobilityContainer'; import SettingsContainer from 'views/settings/SettingsContainer'; import AboutContainer from 'views/static/AboutContainer'; import ContributeContainer from 'views/contribute/ContributeContainer'; @@ -34,6 +35,9 @@ const Routes: React.FC = () => ( {/* END LEGACY ROUTES */} + + + diff --git a/website/static/base/android-icon-bus-512x512.png b/website/static/base/android-icon-bus-512x512.png new file mode 100644 index 0000000000..9a87efa9d2 Binary files /dev/null and b/website/static/base/android-icon-bus-512x512.png differ diff --git a/website/static/base/android-icon-mobility-192x192.png b/website/static/base/android-icon-mobility-192x192.png new file mode 100644 index 0000000000..e1ed56975d Binary files /dev/null and b/website/static/base/android-icon-mobility-192x192.png differ diff --git a/website/static/base/apple-icon-mobility-180x180.png b/website/static/base/apple-icon-mobility-180x180.png new file mode 100644 index 0000000000..db6821b368 Binary files /dev/null and b/website/static/base/apple-icon-mobility-180x180.png differ diff --git a/website/webpack/webpack.config.common.js b/website/webpack/webpack.config.common.js index 9a0904763b..5104690b9c 100644 --- a/website/webpack/webpack.config.common.js +++ b/website/webpack/webpack.config.common.js @@ -70,6 +70,13 @@ const commonConfig = { ], use: ['babel-loader'], }, + { + test: /\.svg$/i, + issuer: /\.[jt]sx?$/, + resourceQuery: /svgr/, // exclude react component if *.svg?url + use: ['@svgr/webpack'], + // for loading SVGs as a React component + }, ], }, }; diff --git a/website/yarn.lock b/website/yarn.lock index 14d7d7be49..4c52383a02 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -7720,6 +7720,11 @@ leaflet-gesture-handling@1.2.2: resolved "https://registry.yarnpkg.com/leaflet-gesture-handling/-/leaflet-gesture-handling-1.2.2.tgz#ea10afb94f2d477d77d47beb21e409ed327df07a" integrity sha512-Blf5V4PoNphWkzL7Y1qge+Spkd4OCQ2atjwUNhMhLIcjKzPcBH++x/lwOinaR9jSqLWqJ6oKYO8d0XdTffy4hQ== +leaflet-polylineoffset@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/leaflet-polylineoffset/-/leaflet-polylineoffset-1.1.1.tgz#021b717d4f7d462f742c1ac1ac0e83e8eef74615" + integrity sha512-WcEjAROx9IhIVwSMoFy9p2QBCG9YeuGtJl4ZdunIgj4xbCdTrUkBj8JdonUeCyLPnD2/Vrem/raOPHm5LvebSw== + leaflet@1.9.4: version "1.9.4" resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d"