:root{padding:env(safe-area-inset-top,0px) 0 env(safe-area-inset-bottom,0px)}[hidden]{display:none!important}
We toured Karachi’s Red Line to bring you this update
/* Layout: one sticky, full-viewport map stage. The page’s scroll position drives a camera over the base map;
labels, video frames and the stop bar are laid over the stage and all read from the same scroll progress. */
:root{
–paper:#ECF0F0; /* the base map’s ground colour, so the page and the map are one surface */
–ink:#37363B; /* label and text colour from the reference */
–ink-soft:#6A6D74;
–ring:#111214; /* stop marker outline */
–red:#EE0608; /* the bus route */
–frame:#F2F2F2; /* video frame body */
–line:#4C4A4B; /* video frame outline */
–white:#FFFFFF;
–chip:rgba(243,244,246,.9);
–bar:rgba(241,239,252,.9);
–shade:rgba(43,58,79,.16);
–display:’Raleway’,’Avenir Next’,’Segoe UI’,Helvetica,Arial,sans-serif;
–text:’Newsreader’,Georgia,’Times New Roman’,serif;
–mono:ui-monospace,’SF Mono’,Menlo,Consolas,monospace;
color-scheme:light; /* single look by choice: the supplied map is a light map */
}
*,*::before,*::after{box-sizing:border-box}
html{scroll-behavior:auto}
body{margin:0;background:var(–paper);color:var(–ink);font-family:var(–text);font-size:17px;line-height:1.45;-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
/* ———- Story + stage ———- */
.story{position:relative;height:1200vh}
.stage{position:sticky;top:env(safe-area-inset-top,0px);height:100vh;height:calc(100dvh – env(safe-area-inset-top,0px) – env(safe-area-inset-bottom,0px));overflow:hidden;background:var(–paper);touch-action:pan-y}
.step{position:absolute;left:0;width:1px;pointer-events:none}
.mapc{position:absolute;left:0;top:0;width:100%;height:100%;display:block}
.route{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}
.bus{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:4;overflow:visible} /* above the stop circles (2-3), below the stop names (4, later in the page) */
.intro .w{display:inline-block}
.route path{fill:none;stroke:var(–red);stroke-linecap:round;stroke-linejoin:round;vector-effect:non-scaling-stroke}
/* ———- Stop markers ———- */
.mk{position:absolute;left:0;top:0;z-index:2;width:30px;height:30px;margin:-15px 0 0 -15px;padding:0;border:0;background:none;display:grid;place-items:center;cursor:pointer;-webkit-tap-highlight-color:transparent}
.mk i{display:block;font-size:var(–mk,11px);width:1em;height:1em;border-radius:50%;background:var(–white);border:.2em solid var(–ring);transition:transform .5s cubic-bezier(.2,.8,.2,1),border-color .3s ease,border-width .3s ease}
.mk:hover i{transform:scale(1.25)}
.mk.is-active{z-index:3}
.mk.is-active i{transform:scale(1.75);border-color:var(–red);border-width:.15em}
.mk:focus-visible{outline:2px solid var(–ink);outline-offset:2px;border-radius:50%}
/* ———- Map labels ———- */
.lab{position:absolute;left:0;top:0;z-index:4;white-space:nowrap;font-family:var(–display);font-weight:800;line-height:1;color:var(–ink);background:var(–chip);padding:.3em .5em .32em;border-radius:3px;box-shadow:0 1px 7px var(–shade);opacity:0;transition:opacity .35s ease;pointer-events:none}
.lab–focus{font-size:var(–fl,20px)}
.lab–over{font-size:var(–el,15px);font-weight:700}
.lab.is-on,.lab.is-hover{opacity:1}
/* ———- Opening text ———- */
.intro{position:absolute;z-index:6;display:flex;flex-direction:column;gap:12px;align-items:flex-start}
.intro–panel{left:42px;top:42px;width:452px;max-width:calc(100vw – 84px);padding:25px;background:rgba(236,240,240,.92);box-shadow:0 2px 18px var(–shade)}
.intro–stack{left:0;right:0;top:0;padding:20px 16px 0}
.intro–stack.on-map{padding-bottom:34px;background:linear-gradient(rgba(236,240,240,.95) 0%,rgba(236,240,240,.9) 72%,rgba(236,240,240,0) 100%)}
.kicker{margin:0;font:700 12px/1 var(–display);letter-spacing:.14em;text-transform:uppercase;color:var(–red)}
.intro .hl{font-style:normal;color:var(–red)}
.intro h1,.intro h2{margin:0;font:800 clamp(27px,3.1vw,46px)/1.04 var(–display);letter-spacing:-.012em;text-wrap:balance}
.standfirst{margin:0;font-size:clamp(16px,1.25vw,19px);line-height:1.42;max-width:34em;text-wrap:pretty}
.intro–panel h1,.intro–panel h2{font-size:32px}
.note{margin:0;font-size:15px;line-height:1.4;color:var(–ink-soft)}
.intro–panel .standfirst{font-size:17px}
.key{display:flex;flex-wrap:wrap;gap:6px 18px;margin:2px 0 0;padding:0;list-style:none;font:700 12px/1 var(–display);letter-spacing:.06em;text-transform:uppercase;color:var(–ink-soft)}
.key li{display:flex;align-items:center;gap:8px}
.key .k-line{width:26px;height:4px;border-radius:2px;background:var(–red)}
.key .k-stop{width:11px;height:11px;border-radius:50%;background:var(–white);border:2.5px solid var(–ring)}
.cue{margin-top:2px;padding:9px 0;border:0;background:none;cursor:pointer;font:700 14px/1 var(–display);color:var(–ink);display:inline-flex;align-items:center;gap:8px;border-bottom:2px solid var(–red)}
.cue svg{width:12px;height:12px;animation:nudge 1.8s ease-in-out infinite}
.cue:hover{color:var(–red)}
.cue:focus-visible{outline:2px solid var(–ink);outline-offset:4px}
@keyframes nudge{0%,100%{transform:translateY(-2px)}50%{transform:translateY(3px)}}
/* ———- Video frames ———- */
.vbox{position:absolute;left:0;top:0;z-index:5;padding:var(–pad,14px) var(–pad,14px) 0;background:linear-gradient(#F5F5F5,#EEEEEE);border:2px solid var(–line);border-radius:calc(var(–pad,14px)*.55 + 5px);box-shadow:0 14px 34px rgba(43,58,79,.2),0 2px 6px rgba(43,58,79,.12);
/* Shown and hidden by the script, which plays the chosen pop animation from the stop. */
opacity:0;visibility:hidden;pointer-events:none;will-change:transform,opacity}
.vbox.is-on{opacity:1;visibility:visible;pointer-events:auto}
.vscreen{position:relative;aspect-ratio:9/16;background:var(–white);border:2px solid var(–line);border-radius:calc(var(–pad,14px)*.45 + 4px);overflow:hidden;container-type:inline-size;cursor:pointer}
.vscreen video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;background:#16171a;opacity:0;transition:opacity .3s ease}
.vbox.has-video video{opacity:1}
.vph{position:absolute;inset:0;display:grid;place-content:center;justify-items:center;gap:.6em;padding:8%;text-align:center;font-size:clamp(9.5px,5.4cqw,15px);transition:opacity .2s ease}
.vbox.has-video .vph,.vbox.has-poster .vph{opacity:0}
.vbox.has-poster video{opacity:1}
.vph-kind{font:700 .8em/1 var(–display);letter-spacing:.14em;text-transform:uppercase;color:var(–ink-soft)}
.vph-name{font:800 1.55em/1.05 var(–display);color:var(–ink)}
.vph-file{font:500 .84em/1.25 var(–mono);color:var(–ink-soft);overflow-wrap:anywhere;padding:.3em .6em;border:1px dashed #B9BCC2;border-radius:3px;transition:color .2s ease,border-color .2s ease}
.vbox.is-nudge .vph-file{color:var(–red);border-color:var(–red)}
.vseek{position:absolute;left:0;right:0;bottom:0;height:22px;z-index:2;cursor:pointer;touch-action:none}
.vseek::before{content:””;position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(255,255,255,.4);transition:height .15s ease}
.vprog{position:absolute;left:0;bottom:0;height:4px;width:0;background:var(–red);transition:height .15s ease}
.vthumb{position:absolute;left:0;bottom:-4px;width:13px;height:13px;margin-left:-6.5px;border-radius:50%;background:var(–red);box-shadow:0 1px 4px rgba(0,0,0,.35);opacity:0;transition:opacity .15s ease}
.vseek:hover::before,.vseek:hover .vprog,.vseek.is-drag::before,.vseek.is-drag .vprog{height:6px}
.vseek:hover .vthumb,.vseek.is-drag .vthumb,.vbox.is-playing .vthumb{opacity:1}
.vunmute{position:absolute;left:50%;top:12px;z-index:3;transform:translateX(-50%);display:none;align-items:center;gap:6px;padding:7px 11px 8px;border:0;border-radius:16px;background:rgba(20,22,26,.78);color:#fff;font:700 12px/1 var(–display);white-space:nowrap;cursor:pointer}
.vunmute svg{width:14px;height:14px}
.vbox.is-muted .vunmute{display:inline-flex}
.vbar{height:var(–barh,56px);display:grid;place-items:center}
.vplay{width:calc(var(–barh,56px)*.64);height:calc(var(–barh,56px)*.64);min-width:28px;min-height:28px;padding:0;border-radius:50%;border:2.5px solid var(–line);background:var(–frame);display:grid;place-items:center;cursor:pointer;transition:transform .2s ease}
.vplay:hover{transform:scale(1.07)}
.vplay:focus-visible{outline:2px solid var(–ink);outline-offset:3px}
.vplay svg{width:48%;height:48%;fill:var(–red)}
.vplay .i-play{margin-left:9%}
.vplay .i-pause{display:none}
.vbox.is-playing .i-play{display:none}
.vbox.is-playing .i-pause{display:block}
/* ———- Bottom stop bar ———- */
.dock{position:absolute;z-index:7;left:50%;bottom:12px;transform:translateX(-50%);display:flex;align-items:center;gap:8px;width:min(100% – 28px,clamp(464px,calc(34vw + 104px),984px))}
.snd .i-on{display:none}
.snd[aria-pressed=”true”] .i-on{display:block}
.snd[aria-pressed=”true”] .i-off{display:none}
/* “Click for audio”: sits above the speaker button until sound is playing, then fades away */
.snd-hint{position:absolute;right:-2px;bottom:calc(100% + 10px);padding:7px 10px 8px;border-radius:4px;background:var(–red);color:var(–white);font:700 13px/1 var(–display);white-space:nowrap;pointer-events:none;box-shadow:0 2px 10px var(–shade);transition:opacity .6s ease,transform .6s ease;animation:hintbob 2.4s ease-in-out infinite}
.snd-hint::after{content:””;position:absolute;right:16px;top:100%;border:6px solid transparent;border-top-color:var(–red)}
.snd-hint.is-gone{opacity:0;transform:translateY(6px);animation:none}
.sound:has(.snd-hint:not(.is-gone))::after{display:none}
@keyframes hintbob{0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}}
.credits{align-self:stretch;margin:6px 0 0;padding-top:12px;border-top:1px solid rgba(55,54,59,.18);font:600 12.5px/1.6 var(–display);letter-spacing:.01em;color:var(–ink-soft)}
.credits b{font-weight:800;color:var(–ink)}
.cue-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.snd-mini{position:relative;flex:none;display:grid;place-items:center;width:34px;height:34px;padding:0;border-radius:50%;border:1.5px solid var(–ink);background:none;color:var(–ink);cursor:pointer;transition:background-color .2s ease,color .2s ease,border-color .2s ease}
.snd-mini svg{width:17px;height:17px}
.snd-mini:hover{border-color:var(–red);color:var(–red)}
.snd-mini[aria-pressed=”true”]{background:var(–ink);color:var(–white)}
.snd-mini:focus-visible{outline:2px solid var(–ink);outline-offset:3px}
@media (prefers-reduced-motion:reduce){.snd-hint{animation:none;transition-duration:.01s}}
.stops{flex:1 1 auto;min-width:0;padding:0 8px;background:var(–bar);border-radius:8px;box-shadow:0 1px 10px var(–shade)}
.fullmap{position:relative;flex:none;display:grid;place-items:center;width:44px;height:44px;padding:0;border:0;border-radius:8px;background:var(–bar);box-shadow:0 1px 10px var(–shade);color:var(–ink);cursor:pointer;-webkit-tap-highlight-color:transparent;transition:background-color .2s ease,color .2s ease,opacity .25s ease}
.fullmap svg{width:20px;height:20px;transition:transform .35s cubic-bezier(.2,.8,.2,1)}
.fullmap .i-in{display:none}
.fullmap[aria-pressed=”true”]{background:var(–ink);color:var(–white)}
.fullmap[aria-pressed=”true”] .i-out{display:none}
.fullmap[aria-pressed=”true”] .i-in{display:block}
.fullmap:disabled{opacity:.4;cursor:default}
.fullmap::after{content:attr(data-tip);position:absolute;right:0;bottom:calc(100% + 6px);transform:translateY(4px);padding:6px 9px 7px;border-radius:3px;background:var(–ink);color:var(–white);font:700 13px/1 var(–display);white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}
.fullmap:focus-visible{outline:2px solid var(–ink);outline-offset:2px}
.fullmap:focus-visible::after{opacity:1;transform:none}
@media (hover:hover){
.fullmap:not(:disabled):hover svg{transform:scale(1.12)}
.fullmap:not(:disabled):hover::after{opacity:1;transform:none}
}
.stops ol{position:relative;display:flex;margin:0;padding:0;list-style:none}
.stops.is-reversed ol{flex-direction:row-reverse}
.stops ol::before{content:””;position:absolute;left:calc(100%/24);right:calc(100%/24);top:50%;height:4px;margin-top:-2px;border-radius:2px;background:var(–red)}
.stops li{flex:1 1 0;min-width:0}
.stop{position:relative;display:grid;place-items:center;width:100%;height:44px;padding:0;border:0;background:none;cursor:pointer;-webkit-tap-highlight-color:transparent}
.stop i{display:block;width:13px;height:13px;border-radius:50%;background:var(–white);border:2.5px solid var(–ring);transition:transform .4s cubic-bezier(.2,.8,.2,1),border-color .25s ease,background-color .25s ease}
.stop.is-done i{background:var(–ring)}
.stop.is-active i{background:var(–white)}
/* the highlight that glides from stop to stop, following the bus */
.stops{position:relative}
.puck{position:absolute;left:0;top:0;width:24px;height:24px;margin:-12px 0 0 -12px;border-radius:50%;background:var(–white);border:3.5px solid var(–red);box-shadow:0 1px 5px rgba(43,58,79,.25);pointer-events:none;opacity:0;transition:opacity .3s ease;will-change:transform}
.stop::after{content:attr(data-name);position:absolute;left:50%;bottom:calc(100% + 2px);transform:translate(-50%,4px);padding:6px 9px 7px;border-radius:3px;background:var(–ink);color:var(–white);font:700 13px/1 var(–display);white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .15s ease,transform .15s ease}
.stop:focus-visible{outline:2px solid var(–ink);outline-offset:-5px;border-radius:6px}
.stop:focus-visible::after{opacity:1;transform:translate(-50%,0)}
@media (hover:hover){
.stop:hover i{transform:scale(1.35)}
.stop:hover::after{opacity:1;transform:translate(-50%,0)}
}
@media (prefers-reduced-motion:reduce){
.cue svg{animation:none}
.vbox,.mk i,.stop i,.lab{transition-duration:.01s}
}
/* ———- 3D stop signs ———- */
.signs{position:absolute;inset:0;pointer-events:none;z-index:4;overflow:hidden}
.sign{position:absolute;left:0;top:0;transform-origin:0 0;display:flex;flex-direction:column;align-items:center;visibility:hidden;will-change:transform,opacity}
.sign-board{background:var(–white);border:3px solid var(–ink);border-radius:9px;padding:7px 16px 10px;box-shadow:0 4px 0 rgba(43,58,79,.22);text-align:center;white-space:nowrap}
.sign-tag{display:flex;align-items:center;justify-content:center;gap:6px;margin-bottom:6px;font:800 11px/1 var(–display);letter-spacing:.13em;text-transform:uppercase;color:var(–red)}
.sign-tag i{width:9px;height:9px;border-radius:50%;background:var(–red)}
.sign-board b{display:block;font:800 23px/1.05 var(–display);color:var(–ink)}
.sign-pole{width:8px;height:104px;background:linear-gradient(90deg,#2A2C31,#5A5D64);border-radius:0 0 3px 3px}
Bus route · 12 stops
We toured Karachi’s Red Line to bring you this update
An interactive on what is finished, still under construction and what life beside the work looks like.
- Bus route
- Stop
Story by Muzhira Amin and Zareef Rind
Design by Mohsin Alam and Ibrahim Yahya
Videos edited by Wajid Usman
Bus route · 12 stops
We toured Karachi’s Red Line to bring you this update
An interactive on what is finished, still under construction and what life beside the work looks like.
- Bus route
- Stop
Story by Muzhira Amin and Zareef Rind
Design by Mohsin Alam and Ibrahim Yahya
Videos edited by Wajid Usman
if (location.protocol === ‘file:’) document.write(”);
(() => {
‘use strict’;
/* =====================================================================
EDIT HERE — everything a producer needs to change lives in this block
===================================================================== */
// All media files are served from this absolute asset root.
const ASSET_ROOT = ‘https://i.dawn.com/news/2038976/’;
// Cross-origin images cannot be uploaded to WebGL unless both the image request and server allow CORS.
// Use Canvas 2D for cross-origin assets so the map remains visible without changing server headers.
const ASSET_ROOT_IS_SAME_ORIGIN = (() => {
try { return new URL(ASSET_ROOT, location.href).origin === location.origin; }
catch (e) { return false; }
})();
const VIDEO_DIR = ASSET_ROOT + ‘videos/’;
// Which end of the bottom bar the first stop sits on.
// ‘right’ follows the map (Malir Halt is the eastern end) and example-zoom.jpg.
// Set to ‘left’ to run the bar left to right instead.
const FIRST_STOP_SIDE = ‘right’;
// One entry per stop, in travel order. x / y are positions on the base map, measured on a 2576 x 1319 grid.
// To swap a video, change its `src` (and its `poster`, a still shown before it plays). An empty src (”) means
// that frame does not appear; a stop with both empty shows no frames.
const STATIONS = [
{ id:’malir-halt’, name:’Malir Halt’, x:2191, y:762, videos:{ main:{ src:’station-01-main.mp4′, poster:’station-01-main.jpg’ }, secondary:{ src:’station-01-secondary.mp4′, poster:’station-01-secondary.jpg’ } } },
{ id:’tank-chowk’, name:’Tank Chowk’, x:2248, y:580, videos:{ main:{ src:’station-02-secondary.mp4′, poster:’station-02-secondary.jpg’ }, secondary:{ src:”, poster:” } } }, // one video so far: a single frame
{ id:’safoora-chowrangi’, name:’Safoora Chowk’, x:1926, y:243, videos:{ main:{ src:’station-03-main.mp4′, poster:’station-03-main.jpg’ }, secondary:{ src:’station-03-secondary.mp4′, poster:’station-03-secondary.jpg’ } } },
{ id:’mosamiyat’, name:’Mosamiat Chowrangi’, x:1778, y:272, videos:{ main:{ src:’station-04-main.mp4′, poster:’station-04-main.jpg’ }, secondary:{ src:’station-04-secondary.mp4′, poster:’station-04-secondary.jpg’ } } },
{ id:’karachi-university’,name:’University of Karachi’, x:1461, y:363, videos:{ main:{ src:’station-05-main.mp4′, poster:’station-05-main.jpg’ }, secondary:{ src:’station-05-secondary.mp4′, poster:’station-05-secondary.jpg’ } } },
{ id:’nipa’, name:’Nipa’, x:1256, y:511, videos:{ main:{ src:’station-06-main.mp4′, poster:’station-06-main.jpg’ }, secondary:{ src:’station-06-secondary.mp4′, poster:’station-06-secondary.jpg’ } } },
{ id:’urdu-university’, name:’Urdu University’, x:1170, y:580, videos:{ main:{ src:’station-07-main.mp4′, poster:’station-07-main.jpg’ }, secondary:{ src:”, poster:” } } }, // one video: a single frame
{ id:’bait-ul-mukarram’, name:’Bait-ul-Mukarram’, x:1113, y:623, videos:{ main:{ src:’station-07-secondary.mp4′, poster:’station-07-secondary.jpg’ }, secondary:{ src:”, poster:” } } }, // one video (filmed as station 07’s second): a single frame
{ id:’expo-centre’, name:’Expo Centre’, x:1022, y:692, videos:{ main:{ src:’station-09-main.mp4′, poster:’station-09-main.jpg’ }, secondary:{ src:’station-09-secondary.mp4′, poster:’station-09-secondary.jpg’ } } },
{ id:’askari-park’, name:’Askari Park’, x:895, y:796, videos:{ main:{ src:’station-10-main.mp4′, poster:’station-10-main.jpg’ }, secondary:{ src:’station-10-secondary.mp4′, poster:’station-10-secondary.jpg’ } } },
{ id:’islamia-college’, name:’Dawood University’, x:716, y:976, videos:{ main:{ src:’station-11-main.mp4′, poster:’station-11-main.jpg’ }, secondary:{ src:’station-11-secondary.mp4′, poster:’station-11-secondary.jpg’ } } },
{ id:’numaish’, name:’Numaish Chowrangi’, x:596, y:1017, videos:{ main:{ src:’station-12-main.mp4′, poster:’station-12-main.jpg’ }, secondary:{ src:’station-12-secondary.mp4′, poster:’station-12-secondary.jpg’ } } }
];
// How the two video frames sit around each stop. Options on desktop:
// ‘split-left’ main frame left of the stop, second frame below-right ‘split-right’ the mirror image
// ‘split-left-low’ / ‘split-right-low’ the same, with the stop low on screen
// ‘pair-left’ both frames side by side to the left of the stop ‘pair-right’ to the right
// ‘above’ both frames above the stop ‘below’ both below it
// ‘corner-up-left’, ‘corner-up-right’, ‘corner-down-left’, ‘corner-down-right’ both frames in one quarter
// Phones use a matching arrangement automatically. Frames never cover the red line already drawn (nor, while
// scrolling, the bus or the stop sign): if the choice here would, the nearest arrangement that keeps them clear is used.
const FRAME_LAYOUT = {
‘malir-halt’: ‘pair-left’, ‘tank-chowk’: ‘split-left-low’, ‘safoora-chowrangi’: ‘below’,
‘mosamiyat’: ‘split-right’, ‘karachi-university’: ‘pair-right’, ‘nipa’: ‘split-left’,
‘urdu-university’: ‘split-right’, ‘bait-ul-mukarram’: ‘pair-left’, ‘expo-centre’: ‘below’,
‘askari-park’: ‘split-right-low’, ‘islamia-college’: ‘pair-right’, ‘numaish’: ‘above’
};
const FRAME_SCALE = .75; // desktop frame size relative to the largest that fits
const PHONE_FRAME_SCALE = 1; // phone frame size: the pair spans the screen width
// How the frames pop out of their stop. Bloom: they grow out of the stop while coming into focus.
// Other styles kept for later: ‘pop’, ‘iris’, ‘unfold’, ‘swing’, ‘fly’, ‘elastic’.
const POP_STYLE = ‘bloom’;
// Stops named on the opening map. The first and last stay named on the closing map as well.
const OPENING_LABELS = [‘malir-halt’, ‘safoora-chowrangi’, ‘nipa’, ‘expo-centre’, ‘islamia-college’, ‘numaish’];
// The red line, traced from example.jpg. ROUTE[i] is the path from STATIONS[i] to STATIONS[i+1],
// on the same 2576 x 1319 grid. The camera rides along these paths between stops.
const ROUTE = [
[[2191,762],[2201,726],[2248,580]],
[[2248,580],[2257,574],[2284,564],[2291,555],[2291,545],[2277,518],[2259,471],[2254,463],[2246,456],[2216,441],[2203,431],[2193,409],[2166,327],[2161,307],[2156,303],[2137,301],[2082,289],[2007,255],[1926,243]],
[[1926,243],[1789,271],[1778,272]],
[[1778,272],[1766,276],[1665,296],[1592,316],[1525,336],[1461,363]],
[[1461,363],[1407,393],[1376,408],[1268,499],[1256,511]],
[[1256,511],[1232,529],[1170,580]],
[[1170,580],[1113,623]],
[[1113,623],[1022,692]],
[[1022,692],[924,765],[905,782],[895,796]],
[[895,796],[832,872],[800,918],[725,968],[716,976]],
[[716,976],[704,982],[658,1013],[649,1017],[596,1017]]
];
// Coordinate grid for STATIONS and ROUTE. The map image itself can be any resolution.
const MAP = { w:2576, h:1319 };
// The base map (MAP_1.svg) is cut into tiles at four sharpness levels: tiles/–.webp.
// The page loads only the tiles the screen is showing, at the sharpness that screen can display.
// Rebuild them from a new map with tools/make_tiles.py, then paste the `levels` line it prints.
const TILES = {
dir: ASSET_ROOT + ’tiles/’, ext: ‘.webp’, gutter: 2,
levels: [ { w:1327, h:680, cols:2, rows:1 }, { w:2654, h:1360, cols:4, rows:2 }, { w:5309, h:2720, cols:8, rows:4 }, { w:10618, h:5440, cols:16, rows:8 } ]
};
// Sound. Run tools/prepare_audio.py on the background track and the long recording to make these files.
// The background loops under the whole story. Each time the bus closes in on a station, one of the
// station clips (20 s sections taken at random from the long recording) is picked at random and faded
// in on top; it fades out as the bus leaves. Sound is on from the start (browsers only let it begin at the
// reader’s first tap, click or key press); the speaker button mutes it.
const AUDIO = {
dir: ASSET_ROOT + ‘audio/’,
background: ‘background.mp3’, // a 150 s seamless loop from the traffic recording
// 20 s sections taken at random from the 2 h 40 min construction-site recording
stationClips: [‘station-clip-01.mp3’, ‘station-clip-02.mp3’, ‘station-clip-03.mp3’, ‘station-clip-04.mp3’,
‘station-clip-05.mp3’, ‘station-clip-06.mp3’, ‘station-clip-07.mp3’, ‘station-clip-08.mp3’,
‘station-clip-09.mp3’, ‘station-clip-10.mp3’, ‘station-clip-11.mp3’, ‘station-clip-12.mp3’,
‘station-clip-13.mp3’, ‘station-clip-14.mp3’, ‘station-clip-15.mp3’, ‘station-clip-16.mp3’],
backgroundVolume: .6, // 0 to 1
stationVolume: .7,
backgroundUnderStation: .5, // the background dips to this share while a station clip plays
engine: ‘bus-engine.mp3’, // loops while the bus moves; louder and higher-pitched the faster it goes
brakes: ‘bus-brakes.mp3’, // air-brake hiss as the bus pulls into a station
backgroundWhileMoving: .3, // the background drops to this share while the bus drives, so the engine is heard
backgroundUnderBrakes: .35, // … and while the brakes hiss
underVideo: .15, // the whole soundscape dips to this share while a station video plays
engineVolume: .9,
brakesVolume: .4,
fadeIn: 2.2, // seconds
fadeOut: 2.6
};
// Bus length relative to its original proportions (1 = the first version, 1.25 = a quarter longer).
const BUS_LENGTH = 1.25;
// Camera. While scrolling, the map is a ground plane and the camera follows behind the bus; the opening and
// closing maps, the full-map button and the stop bar use the flat map.
const CAMERA = {
chaseTilt: 54, // degrees from straight down while following the bus (0 = flat map)
maxRay: 74, // the top of the screen never looks flatter than this, so the horizon stays out of view
chaseMinTilt: 38, // to see further out it may tilt a little less, but not below this
minTilt: 30, // right at the map’s edges it may look more steeply down still, but not below this
zoomOut: .8, // the camera pulls back as far as the map allows, up to this zoom (smaller = further out) …
phoneZoomOut: 2.2, // … on phones (further out and the bus gets too small to read)
zoomIn: 4.2, // … and never closer than this
busGap: .82, // while scrolling, the bus stops this many bus lengths short of each stop circle
fov: 40, // lens angle in degrees: wider gives stronger perspective
fog: [3.2, 9], // very distant map softens into the page colour between these multiples of the camera distance
mode: ‘3d’ // ‘3d’, or ‘flat’ to keep the map flat (the camera still turns with the road)
};
// Phones use their own portrait map for the full-map views (opening, closing, full-map button).
// It is the same map extended north and south; x / y / scale place its pixels on the 2576 x 1319 grid.
const PHONE_MAP = { src: ASSET_ROOT + ’tiles/phone-map.webp’, w: 1996, h: 4000, x: 7.537, y: -2416.313, scale: 1.29009 };
// ‘blend’: zooming into a stop fades from the phone map to the detailed map, which is sharper close up.
// ‘phone-map’: keep the phone map at every zoom (softer close up).
const PHONE_DETAIL = ‘blend’;
/* ============================ end of edit block ============================ */
// Opened straight from disk (double-clicked): the map and sounds come from offline.js, which the page loads
// only in that case (browsers block a page on disk from reading its own image and sound files for drawing).
if (window.OFFLINE) { TILES.inline = OFFLINE.tiles; PHONE_MAP.src = OFFLINE.phone; AUDIO.inline = OFFLINE.audio; if (OFFLINE.clips) AUDIO.stationClips = OFFLINE.clips; }
const N = STATIONS.length;
const $ = (s) => document.querySelector(s);
const stage = $(‘#stage’), story = $(‘#story’);
const markersEl = $(‘#markers’), labelsEl = $(‘#labels’), stepsEl = $(‘#steps’);
const intro = $(‘#intro’), outro = $(‘#outro’), nav = $(‘#stops’), navList = $(‘#stopsList’);
const motion = matchMedia(‘(prefers-reduced-motion: reduce)’);
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b – a) * t;
const ease = (t) => (t String(n).padStart(2, ‘0’);
/* ———- route geometry ———- */
const SEGS = ROUTE.map((pts) => {
const cum = [0];
for (let i = 1; i < pts.length; i++) cum.push(cum[i – 1] + Math.hypot(pts[i][0] – pts[i – 1][0], pts[i][1] – pts[i – 1][1]));
return { pts, cum, len: cum[cum.length – 1] };
});
function along(seg, t) {
const d = t * seg.len;
let i = 1;
while (i < seg.cum.length – 1 && seg.cum[i] (i ? pts.slice(1) : pts));
const ALL_CUM = [0];
for (let i = 1; i STOP_AT.push(STOP_AT[i] + g.len));
function pathTo(len) { // the red line from Malir Halt up to `len`
if (len <= .5) return '';
let d = 'M' + ALL[0].join(' ');
for (let i = 1; i < ALL.length; i++) {
if (ALL_CUM[i] <= len) { d += 'L' + ALL[i].join(' '); continue; }
const u = (len – ALL_CUM[i – 1]) / (ALL_CUM[i] – ALL_CUM[i – 1]);
d += 'L' + lerp(ALL[i – 1][0], ALL[i][0], u).toFixed(2) + ' ' + lerp(ALL[i – 1][1], ALL[i][1], u).toFixed(2);
break;
}
return d;
}
function pointAt(len) { // (a negative len continues back past Malir Halt, where the bus waits)
let i = 1;
while (i < ALL.length – 1 && ALL_CUM[i] < len) i++;
const u = clamp((len – ALL_CUM[i – 1]) / ((ALL_CUM[i] – ALL_CUM[i – 1]) || 1), i === 1 ? -5 : 0, 1);
return [lerp(ALL[i – 1][0], ALL[i][0], u), lerp(ALL[i – 1][1], ALL[i][1], u)];
}
// The line as closely spaced points, used to keep video frames off the part already drawn.
const DENSE = [];
for (let d = 0; d ({ x0: Math.min(b.x0, p[0]), y0: Math.min(b.y0, p[1]), x1: Math.max(b.x1, p[0]), y1: Math.max(b.y1, p[1]) }), { x0: 1e9, y0: 1e9, x1: -1e9, y1: -1e9 });
/* ———- build the DOM that repeats per stop ———- */
const markers = [], focusLabels = [], dots = [];
STATIONS.forEach((S, i) => {
const step = document.createElement(‘section’);
step.className = ‘step’; step.id = ‘station-‘ + pad2(i + 1);
step.innerHTML = ‘
Stop ‘ + (i + 1) + ‘ of ‘ + N + ‘: ‘ + S.name + ‘
‘;
stepsEl.appendChild(step);
const mk = document.createElement(‘button’);
mk.type = ‘button’; mk.className = ‘mk’; mk.tabIndex = -1;
mk.setAttribute(‘aria-label’, S.name);
mk.innerHTML = ‘‘;
mk.addEventListener(‘click’, () => goTo(i + 1, true));
mk.addEventListener(‘pointerenter’, () => { if (!(introAlpha > .2 && OPENING_LABELS.includes(S.id))) focusLabels[i].classList.add(‘is-hover’); });
mk.addEventListener(‘pointerleave’, () => focusLabels[i].classList.remove(‘is-hover’));
markersEl.appendChild(mk); markers.push(mk);
const lab = document.createElement(‘div’);
lab.className = ‘lab lab–focus’; lab.textContent = S.name;
labelsEl.appendChild(lab); focusLabels.push(lab);
const li = document.createElement(‘li’);
const dot = document.createElement(‘button’);
dot.type = ‘button’; dot.className = ‘stop’; dot.dataset.name = S.name;
dot.setAttribute(‘aria-label’, ‘Stop ‘ + (i + 1) + ‘ of ‘ + N + ‘: ‘ + S.name);
dot.innerHTML = ‘‘;
dot.addEventListener(‘click’, () => goTo(i + 1, true));
li.appendChild(dot); navList.appendChild(li); dots.push(dot);
});
const reversed = FIRST_STOP_SIDE === ‘right’;
nav.classList.toggle(‘is-reversed’, reversed);
navList.addEventListener(‘keydown’, (e) => {
if (e.key !== ‘ArrowLeft’ && e.key !== ‘ArrowRight’) return;
const cur = dots.indexOf(document.activeElement);
if (cur STATIONS.findIndex((S) => S.id === id)).filter((i) => i >= 0).map((i) => {
const el = document.createElement(‘div’);
el.className = ‘lab lab–over’; el.textContent = STATIONS[i].name;
labelsEl.appendChild(el);
return { el, i, end: i === 0 || i === N – 1, side: i === N – 1 ? ‘left’ : ‘right’, w: 0 };
});
/* ———- video frames ———- */
const boxes = [‘main’, ‘secondary’].map((kind) => {
const el = kind === ‘main’ ? $(‘#boxMain’) : $(‘#boxSecondary’);
el.innerHTML =
” +
” +
” +
” +
” +
” +
”;
const box = {
kind, el,
video: el.querySelector(‘video’), screen: el.querySelector(‘.vscreen’),
kindEl: el.querySelector(‘.vph-kind’), nameEl: el.querySelector(‘.vph-name’), fileEl: el.querySelector(‘.vph-file’),
prog: el.querySelector(‘.vprog’), seek: el.querySelector(‘.vseek’), thumb: el.querySelector(‘.vthumb’), play: el.querySelector(‘.vplay’), loadTimer: 0, nudgeTimer: 0
};
const toggle = () => {
if (!el.classList.contains(‘has-video’)) { // no file yet: point at the path that is expected
el.classList.add(‘is-nudge’); clearTimeout(box.nudgeTimer);
box.nudgeTimer = setTimeout(() => el.classList.remove(‘is-nudge’), 1400);
return;
}
if (box.video.paused) { boxes.forEach((b) => { b.wantAuto = false; if (b !== box) b.video.pause(); }); box.video.muted = false; box.video.play().catch(() => {}); }
else box.video.pause();
};
box.play.addEventListener(‘click’, toggle);
box.screen.addEventListener(‘click’, toggle);
box.video.addEventListener(‘loadedmetadata’, () => el.classList.add(‘has-video’));
box.video.addEventListener(‘error’, () => el.classList.remove(‘has-video’, ‘is-playing’));
box.video.addEventListener(‘play’, () => el.classList.add(‘is-playing’));
box.video.addEventListener(‘pause’, () => el.classList.remove(‘is-playing’));
box.video.addEventListener(‘ended’, () => el.classList.remove(‘is-playing’));
const showPos = () => { // the red bar and its handle follow the video
const v = box.video, f = v.duration ? v.currentTime / v.duration : 0;
box.prog.style.width = f * 100 + ‘%’; box.thumb.style.left = f * 100 + ‘%’;
box.seek.setAttribute(‘aria-valuenow’, Math.round(f * 100));
};
box.video.addEventListener(‘timeupdate’, showPos);
// the bar at the bottom of each video: click or drag along it to move back and forth
const seekTo = (e) => {
const v = box.video, r = box.seek.getBoundingClientRect();
if (!v.duration || !r.width) return;
v.currentTime = clamp((e.clientX – r.left) / r.width, 0, 1) * v.duration; showPos();
};
box.seek.addEventListener(‘pointerdown’, (e) => { e.stopPropagation(); e.preventDefault(); box.seek.setPointerCapture(e.pointerId); box.seek.classList.add(‘is-drag’); seekTo(e); });
box.seek.addEventListener(‘pointermove’, (e) => { if (box.seek.classList.contains(‘is-drag’)) seekTo(e); });
const endDrag = () => box.seek.classList.remove(‘is-drag’);
box.seek.addEventListener(‘pointerup’, endDrag); box.seek.addEventListener(‘pointercancel’, endDrag);
box.seek.addEventListener(‘click’, (e) => e.stopPropagation());
box.seek.addEventListener(‘keydown’, (e) => {
const v = box.video; if (!v.duration) return;
if (e.key === ‘ArrowLeft’ || e.key === ‘ArrowRight’) { e.preventDefault(); e.stopPropagation(); v.currentTime = clamp(v.currentTime + (e.key === ‘ArrowRight’ ? 5 : -5), 0, v.duration); showPos(); }
});
// started without sound (the reader hasn’t tapped the page yet): one tap here, or anywhere, brings it in
const mutedState = () => el.classList.toggle(‘is-muted’, box.video.muted && !box.video.paused);
[‘volumechange’, ‘play’, ‘pause’, ‘ended’].forEach((t) => box.video.addEventListener(t, mutedState));
el.querySelector(‘.vunmute’).addEventListener(‘click’, (e) => { e.stopPropagation(); box.video.muted = false; });
return box;
});
let boxStation = -1;
const viewOf = (idx) => (navMode ? L.st[idx].flat : L.st[idx].chase); // the stop bar’s flat view, or the chase view
function placeBoxes(idx) { // geometry only, safe to call on every resize
const v = viewOf(idx), lay = v.variant, q = v.q;
boxes.forEach((box) => {
const r = lay[box.kind], el = box.el;
el.style.left = r.x + ‘px’; el.style.top = r.y + ‘px’; el.style.width = r.w + ‘px’;
el.style.transformOrigin = (q[0] – r.x).toFixed(1) + ‘px ‘ + (q[1] – r.y).toFixed(1) + ‘px’; // pop out of the stop
el.style.setProperty(‘–pad’, r.pad + ‘px’); el.style.setProperty(‘–barh’, r.bar + ‘px’);
});
}
// The single-file version carries its videos and posters inside the page (window.OFFLINE). A packed video is
// turned into a playable in-memory file only when its stop comes up, and kept for the next visit.
const mediaUrl = (kind, name) => {
if (window.OFFLINE && OFFLINE[kind] && OFFLINE[kind][name]) return OFFLINE[kind][name];
const el = kind === ‘videos’ && document.getElementById(‘pack:’ + name); // packed at the end of the single file
return el ? ‘data:video/mp4;base64,’ + el.textContent.trim() : ”;
};
const packedCache = new Map();
function packedVideo(name, dataUrl) {
if (!packedCache.has(name)) packedCache.set(name, fetch(dataUrl).then((r) => r.blob()).then((b) => URL.createObjectURL(b)).catch(() => ”));
return packedCache.get(name);
}
function fillBoxes(idx) { // idx: 0-based stop. Swaps both frames to that stop’s videos.
boxStation = idx;
placeBoxes(idx);
const S = STATIONS[idx];
boxes.forEach((box) => {
const def = S.videos[box.kind], el = box.el;
box.empty = !def.src; // no video for this frame: it stays hidden
box.video.pause();
el.classList.remove(‘has-video’, ‘is-playing’, ‘is-nudge’);
el.classList.toggle(‘has-poster’, !!def.poster);
box.prog.style.width = ‘0’;
box.kindEl.textContent = box.kind === ‘main’ ? ‘Main video’ : ‘Second video’;
box.nameEl.textContent = S.name;
box.fileEl.textContent = VIDEO_DIR + def.src;
box.play.setAttribute(‘aria-label’, ‘Play the ‘ + (box.kind === ‘main’ ? ‘main’ : ‘second’) + ‘ video for ‘ + S.name);
box.video.removeAttribute(‘src’); box.video.removeAttribute(‘poster’);
clearTimeout(box.loadTimer);
box.loadTimer = setTimeout(function load() { // only fetch once the reader has settled on this stop
if (document.readyState === ‘loading’) { document.addEventListener(‘DOMContentLoaded’, load, { once: true }); return; } // single file: still reading its videos
if (def.poster) box.video.poster = mediaUrl(‘posters’, def.poster) || VIDEO_DIR + def.poster;
if (!def.src) return;
const packed = mediaUrl(‘videos’, def.src);
if (!packed) { box.video.src = VIDEO_DIR + def.src; box.video.load(); return; }
packedVideo(def.src, packed).then((url) => { // single-file version: the video is packed inside the page
if (boxStation !== idx || !url) return;
box.video.src = url; box.video.load();
});
}, 320);
});
}
/* ———- the camera: a perspective view of the map lying flat on the ground ———-
A camera is { x, y: the map point it looks at, D: its distance from that point (map units),
pitch: tilt away from straight down (radians), yaw: the map direction that points into the screen,
ox, oy: where the looked-at point sits on screen (CSS px) }. Pitch 0 with yaw NORTH is the flat map. */
const NORTH = -Math.PI / 2, RAD = Math.PI / 180;
const canvas = $(‘#mapCanvas’);
const PAPER = getComputedStyle(document.documentElement).getPropertyValue(‘–paper’).trim() || ‘#ECF0F0’;
const PAPER_RGB = (PAPER.match(/[0-9a-f]{2}/gi) || [‘EC’, ‘F0’, ‘F0’]).map((h) => parseInt(h, 16) / 255);
const smallScreen = matchMedia(‘(pointer: coarse)’).matches;
let FOCAL = 1000; // lens focal length in CSS px, set from CAMERA.fov and the stage height
function view(c, W, H) {
const sp = Math.sin(c.pitch), cp = Math.cos(c.pitch), Fx = Math.cos(c.yaw), Fy = Math.sin(c.yaw), Rx = -Fy, Ry = Fx;
const F = FOCAL, D = c.D;
const V = { c, W, H, F, sp, cp, Fx, Fy, Rx, Ry, near: D * .03, flat: clamp(c.pitch / .38, 0, 1),
fog0: D * CAMERA.fog[0], fog1: D * CAMERA.fog[1] };
V.cam = (wx, wy, h = 0) => { // camera space: x right, y up, z depth along the view
const dx = wx – c.x, dy = wy – c.y, v = dx * Fx + dy * Fy;
return [dx * Rx + dy * Ry, v * cp + h * sp, v * sp – h * cp + D];
};
V.project = (wx, wy, h = 0) => {
const q = V.cam(wx, wy, h);
return q[2] {
const g = V.project(wx, wy, 0);
if (!g || !h) return g;
const t = V.project(wx, wy, h);
if (V.flat >= 1 && t) return t;
const fake = [g[0], g[1] – h * F / g[2], g[2]];
return t ? [lerp(fake[0], t[0], V.flat), lerp(fake[1], t[1], V.flat), g[2]] : fake;
};
return V;
}
function clipMatrix(V, n, f) { // the same projection as view(), as a WebGL matrix
const { c, W, H, F, sp, cp, Fx, Fy, Rx, Ry } = V;
const X = [Rx, Ry, 0, -(Rx * c.x + Ry * c.y)];
const v = [Fx, Fy, 0, -(Fx * c.x + Fy * c.y)];
const Y = [v[0] * cp, v[1] * cp, sp, v[3] * cp];
const Z = [v[0] * sp, v[1] * sp, -cp, v[3] * sp + c.D];
const A = (f + n) / (f – n), B = -2 * f * n / (f – n);
const rows = [X.map((x, i) => (2 / W) * (c.ox * Z[i] + F * x) – Z[i]),
Y.map((y, i) => Z[i] – (2 / H) * (c.oy * Z[i] – F * y)),
Z.map((z, i) => A * z + (i === 3 ? B : 0)),
Z.slice()];
const m = new Float32Array(16);
for (let r = 0; r < 4; r++) for (let k = 0; k Math.floor(i * size / n + .5);
// World-space rectangle of every tile (the gutter makes neighbours overlap slightly, which hides seams).
const GRID = TILES.levels.map((T, lv) => {
const list = [];
for (let cy = 0; cy < T.rows; cy++) for (let cx = 0; cx T.w / MAP.w); // texels per map unit at each level
let pr = 1, mapDirty = true, loading = 0, wanted = [], frameNo = 0, lastPlan = 0, lastCam = null, moving = false, lastDir = 1;
function tileDepth(V, t) { // nearest depth of the visible part of a tile, or null when unseen
const P = [[t.x, t.y], [t.x + t.w, t.y], [t.x + t.w, t.y + t.h], [t.x, t.y + t.h]].map(([x, y]) => V.cam(x, y));
const pts = [];
for (let i = 0; i = V.near, ib = b[2] >= V.near;
if (ia) pts.push(a);
if (ia !== ib) { const u = (V.near – a[2]) / (b[2] – a[2]); pts.push([lerp(a[0], b[0], u), lerp(a[1], b[1], u), V.near]); }
}
if (!pts.length) return null;
let x0 = 1e9, y0 = 1e9, x1 = -1e9, y1 = -1e9, z = 1e9;
for (const q of pts) {
const sx = V.c.ox + V.F * q[0] / q[2], sy = V.c.oy – V.F * q[1] / q[2];
x0 = Math.min(x0, sx); x1 = Math.max(x1, sx); y0 = Math.min(y0, sy); y1 = Math.max(y1, sy); z = Math.min(z, q[2]);
}
if (x1 < -30 || y1 V.W + 30 || y0 > V.H + 30 || z > V.fog1) return null;
return z;
}
function selectTiles(V) { // every visible tile, refined wherever the screen needs more detail
const all = [];
const visit = (lv, cx, cy) => {
const T = TILES.levels[lv];
if (cx >= T.cols || cy >= T.rows) return;
const t = GRID[lv][cy * T.cols + cx], z = tileDepth(V, t);
if (z === null) return;
all.push(t);
const need = V.F * pr / z; // screen px per map unit at its nearest point
const slack = lv === TOP – 1 ? (smallScreen ? .62 : .8) : 1; // a small stretch just below the heaviest level
if (lv < TOP && DENS[lv] < need * slack) for (let j = 0; j > 1));
};
const T0 = TILES.levels[0];
for (let cy = 0; cy < T0.rows; cy++) for (let cx = 0; cx a.lv – b.lv);
}
/* ———- WebGL: the ground, its tiles and the red line, with real perspective ———- */
const GL = (() => {
if (CAMERA.mode === ‘flat’) return null;
let gl = null;
try { gl = canvas.getContext(‘webgl2’, { alpha: false, antialias: false, depth: false, stencil: false, premultipliedAlpha: true }); } catch (e) {}
if (!gl) return null;
const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s)); return s; };
const prog = (vs, fs) => {
const p = gl.createProgram();
gl.attachShader(p, sh(gl.VERTEX_SHADER, ‘#version 300 esn’ + vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, ‘#version 300 esnprecision highp float;n’ + fs));
gl.linkProgram(p);
if (!gl.getProgramParameter(p, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(p));
const u = {}, n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const a = gl.getActiveUniform(p, i); u[a.name] = gl.getUniformLocation(p, a.name); }
return { p, u };
};
try {
const FOG = 'uniform vec2 fog; uniform vec3 paper;';
const tile = prog(
'layout(location = 0) in vec2 a; uniform mat4 m; uniform vec4 rect; out vec2 vuv; out float vz;n' +
'void main(){ vec2 w = rect.xy + a * rect.zw; gl_Position = m * vec4(w, 0., 1.); vuv = a; vz = gl_Position.w; }',
'in vec2 vuv; in float vz; uniform sampler2D tex; uniform float alpha; ' + FOG + ' out vec4 o;n' +
'void main(){ vec3 c = texture(tex, vuv).rgb; float f = smoothstep(fog.x, fog.y, vz); o = vec4(mix(c, paper, f) * alpha, alpha); }');
const line = prog(
'layout(location = 0) in vec4 a; uniform mat4 m; uniform float hw; out float vz;n' +
'void main(){ gl_Position = m * vec4(a.xy + a.zw * hw, 0., 1.); vz = gl_Position.w; }',
'in float vz; uniform vec4 col; ' + FOG + ' out vec4 o;n' +
'void main(){ float f = smoothstep(fog.x, fog.y, vz); o = vec4(mix(col.rgb, paper, f) * col.a, col.a); }');
const blit = prog(
'out vec2 v; void main(){ vec2 p = vec2(gl_VertexID == 1 ? 3. : -1., gl_VertexID == 2 ? 3. : -1.); v = p * .5 + .5; gl_Position = vec4(p, 0., 1.); }',
'in vec2 v; uniform sampler2D tex; uniform vec2 c; uniform float amt; out vec4 o;n' +
'void main(){ if (amt <= 0.) { o = vec4(texture(tex, v).rgb, 1.); return; }n' +
' vec3 s = vec3(0.); for (int i = 0; i { const buf = gl.createBuffer(), vao = gl.createVertexArray(); gl.bindVertexArray(vao); gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 4, gl.FLOAT, false, 0, 0); return { buf, vao, n: 0 }; };
const full = mkLine(), drawn = mkLine();
const emptyVAO = gl.createVertexArray();
gl.bindVertexArray(null);
const aniso = gl.getExtension(‘EXT_texture_filter_anisotropic’);
const maxAniso = aniso ? Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)) : 0;
const maxSamples = gl.getParameter(gl.MAX_SAMPLES) || 0;
let fb = null;
const R = {
gl,
upload(img) {
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.generateMipmap(gl.TEXTURE_2D);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
if (maxAniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, maxAniso);
return tex;
},
drop(tex) { gl.deleteTexture(tex); },
setLine(which, data) { const L2 = which === ‘full’ ? full : drawn; gl.bindBuffer(gl.ARRAY_BUFFER, L2.buf); gl.bufferData(gl.ARRAY_BUFFER, data, gl.DYNAMIC_DRAW); L2.n = data.length / 4; },
resize(w, h) {
if (fb && fb.w === w && fb.h === h) return;
if (fb) { gl.deleteFramebuffer(fb.ms); gl.deleteFramebuffer(fb.rs); gl.deleteRenderbuffer(fb.col); gl.deleteRenderbuffer(fb.st); gl.deleteTexture(fb.tex); }
const samples = Math.min(maxSamples, pr >= 2 ? 2 : 4);
const f = { w, h, ms: gl.createFramebuffer(), rs: gl.createFramebuffer(), col: gl.createRenderbuffer(), st: gl.createRenderbuffer(), tex: gl.createTexture(), samples };
gl.bindTexture(gl.TEXTURE_2D, f.tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.bindFramebuffer(gl.FRAMEBUFFER, f.rs); gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, f.tex, 0);
gl.bindRenderbuffer(gl.RENDERBUFFER, f.st);
if (samples > 0) {
gl.bindRenderbuffer(gl.RENDERBUFFER, f.col); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.RGBA8, w, h);
gl.bindRenderbuffer(gl.RENDERBUFFER, f.st); gl.renderbufferStorageMultisample(gl.RENDERBUFFER, samples, gl.DEPTH24_STENCIL8, w, h);
gl.bindFramebuffer(gl.FRAMEBUFFER, f.ms);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.RENDERBUFFER, f.col);
} else {
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, w, h);
f.ms = f.rs;
gl.bindFramebuffer(gl.FRAMEBUFFER, f.ms);
}
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, f.st);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
fb = f;
},
render(V, tiles, detail, phone, ln, now) {
const w = canvas.width, h = canvas.height;
R.resize(w, h);
const M = clipMatrix(V, V.near, V.c.D * 400);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb.ms);
gl.viewport(0, 0, w, h);
gl.clearColor(PAPER_RGB[0], PAPER_RGB[1], PAPER_RGB[2], 1); gl.clearStencil(0); gl.clearDepth(1);
gl.clear(gl.COLOR_BUFFER_BIT | gl.STENCIL_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.useProgram(tile.p);
gl.uniformMatrix4fv(tile.u.m, false, M);
gl.uniform2f(tile.u.fog, V.fog0, V.fog1); gl.uniform3f(tile.u.paper, PAPER_RGB[0], PAPER_RGB[1], PAPER_RGB[2]);
gl.uniform1i(tile.u.tex, 0); gl.activeTexture(gl.TEXTURE0);
gl.bindVertexArray(quadVAO);
let fading = false;
if (phone) { gl.bindTexture(gl.TEXTURE_2D, phone.tex); gl.uniform4f(tile.u.rect, phone.x, phone.y, phone.w, phone.h); gl.uniform1f(tile.u.alpha, 1); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); }
if (detail > .002) for (const t of tiles) {
if (t.state !== 2) continue;
const a = clamp((now – t.t0) / 240, 0, 1);
if (a < 1) fading = true;
t.used = frameNo;
gl.bindTexture(gl.TEXTURE_2D, t.tex);
gl.uniform4f(tile.u.rect, t.x, t.y, t.w, t.h); gl.uniform1f(tile.u.alpha, a * detail);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
// the red line, each pixel painted once (the stencil stops overlapping pieces doubling up)
gl.useProgram(line.p);
gl.uniformMatrix4fv(line.u.m, false, M);
gl.uniform2f(line.u.fog, V.fog0, V.fog1); gl.uniform3f(line.u.paper, PAPER_RGB[0], PAPER_RGB[1], PAPER_RGB[2]);
gl.uniform1f(line.u.hw, ln.hw);
gl.enable(gl.STENCIL_TEST);
for (const [L2, alpha] of [[drawn, 1], [full, ln.full]]) {
if (!L2.n || alpha .0015 ? blur.amt * 2.2 : 0);
gl.bindVertexArray(emptyVAO); gl.drawArrays(gl.TRIANGLES, 0, 3); gl.bindVertexArray(null);
return fading;
}
};
return R;
} catch (err) { console.warn(‘3D map unavailable, using the flat map’, err); return null; }
})();
if (GL) { // if the graphics driver resets, start the page again cleanly
canvas.addEventListener(‘webglcontextlost’, (e) => e.preventDefault());
canvas.addEventListener(‘webglcontextrestored’, () => location.reload());
}
const FLAT = !GL; // without WebGL2 the map stays flat (the camera still turns with the road)
const ctx = GL ? null : canvas.getContext(‘2d’, { alpha: false });
// The red line as ground geometry: each piece carries its centre point and a unit offset, the width is a uniform.
function lineMesh(pts) {
const out = [];
const disc = (p) => { for (let k = 0; k < 12; k++) { const a0 = k / 12 * 6.2832, a1 = (k + 1) / 12 * 6.2832;
out.push(p[0], p[1], 0, 0, p[0], p[1], Math.cos(a0), Math.sin(a0), p[0], p[1], Math.cos(a1), Math.sin(a1)); } };
for (let i = 1; i < pts.length; i++) {
const a = pts[i – 1], b = pts[i], dx = b[0] – a[0], dy = b[1] – a[1], l = Math.hypot(dx, dy) || 1, nx = -dy / l, ny = dx / l;
out.push(a[0], a[1], nx, ny, b[0], b[1], nx, ny, b[0], b[1], -nx, -ny, a[0], a[1], nx, ny, b[0], b[1], -nx, -ny, a[0], a[1], -nx, -ny);
}
pts.forEach(disc);
return new Float32Array(out);
}
function pointsTo(len) { // the red line from Malir Halt up to `len`, as points
if (len <= .5) return [];
const out = [ALL[0]];
for (let i = 1; i < ALL.length; i++) {
if (ALL_CUM[i] {
if (src && GL) { t.tex = GL.upload(src); if (src.close) src.close(); src = true; }
t.src = src; t.state = src ? 2 : 3; t.t0 = performance.now(); t.used = frameNo; loading–; mapDirty = true; pump(); trim();
};
img.onload = () => {
if (window.createImageBitmap) createImageBitmap(img).then(done, () => done(img)); else done(img);
};
img.onerror = () => done(null);
img.src = t.url;
}
function pump() { while (loading < 6 && wanted.length) { const t = wanted.shift(); if (t.state === 0) loadTile(t); } }
function trim() { // keep memory bounded: drop detailed tiles that are far from view
const cap = smallScreen ? 44 : 120, keep = new Set(wanted);
const held = [];
for (let lv = 2; lv <= TOP; lv++) for (const t of GRID[lv]) if (t.state === 2) held.push(t);
if (held.length a.used – b.used);
for (let i = 0; i < held.length – cap; i++) {
const t = held[i];
if (keep.has(t) || frameNo – t.used { if (!seen.has(t)) { seen.add(t); if (t.state === 0) list.push(t); } };
for (const t of GRID[0]) take(t);
const add = (c) => { for (const t of selectTiles(view(c, L.W, L.H))) take(t); };
add(cam);
for (const t of GRID[1]) take(t);
const p = progress(), dest = jump ? jump.k : Math.round(p);
add(camOf(dest)); // where the reader is about to land
if (!moving && !jump) { // then the road ahead and the stop after it
const next = clamp(dest + lastDir, 1, N);
if (dest >= 1 && dest .05) { meshLine = lineNow; GL.setLine(‘drawn’, lineMesh(pointsTo(lineNow))); }
fading = GL.render(V, tiles, detail, phone, { hw: sw / 2 * cam.D / FOCAL, full: fullNow, rgb: RED_RGB }, now);
} else fading = drawFlat(V, tiles, detail, phone, sw, now);
mapDirty = fading || blur.amt > .0015;
}
function drawFlat(V, tiles, detail, phone, sw, now) { // canvas fallback: the map turns and zooms but stays flat
const c = V.c, s = V.F / c.D;
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1; ctx.fillStyle = PAPER; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.setTransform(pr * s * V.Rx, -pr * s * V.Fx, pr * s * V.Ry, -pr * s * V.Fy,
pr * (c.ox – s * (V.Rx * c.x + V.Ry * c.y)), pr * (c.oy + s * (V.Fx * c.x + V.Fy * c.y)));
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = ‘high’;
if (phone) ctx.drawImage(phone.src, phone.x, phone.y, phone.w, phone.h);
let fading = false;
if (detail > .002) for (const t of tiles) {
if (t.state !== 2) continue;
const a = clamp((now – t.t0) / 240, 0, 1);
if (a { if (pts.length ctx.lineTo(p[0], p[1])); ctx.stroke(); };
if (fullNow > .003) { ctx.globalAlpha = fullNow; stroke(ALL); ctx.globalAlpha = 1; }
stroke(pointsTo(lineNow));
ctx.setTransform(1, 0, 0, 1, 0, 0);
return fading;
}
const blur = { amt: 0, x: 0, y: 0 };
function updateBlur(prev, next, dt) { // zoom blur: strength follows how fast the camera closes in or pulls away
const rate = Math.abs(Math.log(next.D / prev.D)) / Math.max(dt, .008);
const want = motion.matches ? 0 : clamp((rate – .12) * .028, 0, .036);
blur.amt = lerp(blur.amt, want, want > blur.amt ? .5 : .3);
if (blur.amt {
const done = (b) => { if (GL) { phoneMap.tex = GL.upload(b); } else phoneMap.src = b; phoneMap.ready = true; mapDirty = true; };
if (window.createImageBitmap) createImageBitmap(img).then(done, () => done(img)); else done(img);
};
img.src = PHONE_MAP.src;
}
/* ———- layout: where the frames, the focus point and the cameras sit for this screen ———- */
let L = null, stepH = 800, storyTop = 0, lastW = 0, lastH = 0;
const measure = document.createElement(‘canvas’).getContext(‘2d’);
function boxRect(x, y, w) { // a portrait frame: 9:16 screen plus the play bar
w = Math.round(w);
const pad = Math.max(6, Math.round(w * .04)), bar = Math.max(Math.round(w * .15), 40);
const h = pad + (w – 2 * pad – 4) * 16 / 9 + bar + 4;
return { x: Math.round(x), y: Math.round(y), w, h, pad, bar };
}
function widthFor(h) { // frame width whose total height is h
let w = h / 1.9;
for (let k = 0; k boxRect(x, y, r.w);
const PHONE_LAYOUT = { // the phone arrangement used for each desktop arrangement
‘split-left’: ‘bottom-right’, ‘split-right’: ‘bottom-left’, ‘split-left-low’: ‘top-right’, ‘split-right-low’: ‘top-left’,
‘pair-left’: ‘bottom-right’, ‘pair-right’: ‘bottom-left’, ‘above’: ‘top-left’, ‘below’: ‘bottom-right’
};
// Frame arrangements. Each one places the two frames around the stop’s actual screen position,
// so when the map edge pushes a stop off-centre the frames move with it.
function arrangements(W, H, navRes, wide) {
const bottom = H – navRes;
if (wide) {
const top = Math.max(16, .06 * H), g = Math.max(30, .024 * W), lg = Math.max(30, .04 * H); // g clears the stop marker
const mw0 = Math.min(widthFor((bottom – top) * .9), .3 * W) * FRAME_SCALE;
const A = (name, ix, iy, side, place) => ({ name, ideal: [ix, iy], side, place });
return { top, bottom, mw0, ratio: .82, list: [
A(‘split-left’, .56, .4, ‘right’, (m, c, x, y) => ({ main: boxRect(x – g – m.w, y – .42 * m.h, m.w), secondary: boxRect(x – 4, y + lg, c.w) })),
A(‘split-right’, .44, .4, ‘left’, (m, c, x, y) => ({ main: boxRect(x + g, y – .42 * m.h, m.w), secondary: boxRect(x + 4 – c.w, y + lg, c.w) })),
A(‘split-left-low’, .56, .62, ‘right’, (m, c, x, y) => ({ main: boxRect(x – g – m.w, y – .58 * m.h, m.w), secondary: boxRect(x – 4, y – lg – c.h, c.w) })),
A(‘split-right-low’, .44, .62, ‘left’, (m, c, x, y) => ({ main: boxRect(x + g, y – .58 * m.h, m.w), secondary: boxRect(x + 4 – c.w, y – lg – c.h, c.w) })),
A(‘pair-right’, .3, .5, ‘left’, (m, c, x, y) => { const mx = x + g, my = y – .55 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx + m.w + g, my + .22 * m.h, c.w) }; }),
A(‘pair-left’, .7, .5, ‘right’, (m, c, x, y) => { const mx = x – g – m.w, my = y – .55 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx – g – c.w, my + .22 * m.h, c.w) }; }),
A(‘above’, .5, .76, ‘right’, (m, c, x, y) => { const mx = x – .88 * m.w; return { main: boxRect(mx, y – lg – m.h, m.w), secondary: boxRect(mx + m.w + g, y – lg – c.h – .14 * c.h, c.w) }; }),
A(‘below’, .5, .22, ‘right’, (m, c, x, y) => { const mx = x – .88 * m.w; return { main: boxRect(mx, y + lg, m.w), secondary: boxRect(mx + m.w + g, y + lg + .14 * c.h, c.w) }; }),
// corners: both frames tucked into one quarter around the stop, for lines that run diagonally
A(‘corner-up-left’, .66, .7, ‘right’, (m, c, x, y) => { const mx = x – g – m.w, my = y – .82 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx – g – c.w, my – .1 * c.h, c.w) }; }),
A(‘corner-up-right’, .34, .7, ‘left’, (m, c, x, y) => { const mx = x + g, my = y – .82 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx + m.w + g, my – .1 * c.h, c.w) }; }),
A(‘corner-down-left’, .66, .3, ‘right’, (m, c, x, y) => { const mx = x – g – m.w, my = y – .18 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx – g – c.w, my + m.h – c.h + .1 * c.h, c.w) }; }),
A(‘corner-down-right’, .34, .3, ‘left’, (m, c, x, y) => { const mx = x + g, my = y – .18 * m.h; return { main: boxRect(mx, my, m.w), secondary: boxRect(mx + m.w + g, my + m.h – c.h + .1 * c.h, c.w) }; })
] };
}
// phones: the frames sit along the top or bottom edge (or in one column), the stop in the open part
const top = 14, gap = 10, zone = 150, avail = Math.min(W – 28, 560);
let mw0 = (avail – gap) * .56 * PHONE_FRAME_SCALE;
if (boxRect(0, 0, mw0).h > bottom – top – zone) mw0 = widthFor(bottom – top – zone);
const row = (name, low, toLeft) => ({ name, side: ‘right’, ideal: [toLeft ? .62 : .22, low ? .3 : .72], place: (m, c) => {
// the pair sits flush along the top or bottom edge, edges aligned
const x0 = toLeft ? 14 : W – 14 – (m.w + gap + c.w);
return { main: boxRect(toLeft ? x0 : x0 + c.w + gap, low ? bottom – m.h : top, m.w), secondary: boxRect(toLeft ? x0 + m.w + gap : x0, low ? bottom – c.h : top, c.w) };
} });
const column = (name, toLeft) => ({ name, side: toLeft ? ‘left’ : ‘right’, ideal: [toLeft ? .92 : .08, .45], place: (m, c) => {
const k = Math.min(1, (bottom – top – gap) / (m.h + c.h)), mm = boxRect(0, 0, m.w * k), cc = boxRect(0, 0, m.w * k * .8);
const x = toLeft ? 14 : W – 14 – mm.w, y0 = top + (bottom – top – mm.h – gap – cc.h) / 2;
return { main: boxRect(x, y0, mm.w), secondary: boxRect(toLeft ? x + mm.w * .2 : x, y0 + mm.h + gap, cc.w) };
} });
return { top, bottom, mw0, ratio: .78, list: [row(‘bottom-left’, true, true), row(‘bottom-right’, true, false), row(‘top-left’, false, true), row(‘top-right’, false, false), column(‘column-left’, true), column(‘column-right’, false)] };
}
function slackOf(c, W, H) { // how far a camera lets the page ground show past each map edge
return { l: Math.max(0, c.tx), r: Math.max(0, (W – MAP.w * c.s) – c.tx), t: Math.max(0, c.ty), b: Math.max(0, (H – MAP.h * c.s) – c.ty) };
}
function hold(c, sl, W, H) { // keep the map covering the stage, apart from the allowed slack
let lo = W – MAP.w * c.s – sl.r, hi = sl.l;
if (lo > hi) lo = hi = (lo + hi) / 2;
c.tx = clamp(c.tx, lo, hi);
lo = H – MAP.h * c.s – sl.b; hi = sl.t;
if (lo > hi) lo = hi = (lo + hi) / 2;
c.ty = clamp(c.ty, lo, hi);
return c;
}
const lerpSlack = (a, b, t) => ({ l: lerp(a.l, b.l, t), r: lerp(a.r, b.r, t), t: lerp(a.t, b.t, t), b: lerp(a.b, b.b, t) });
const hits = (a, b, m) => a[0] b.x – m && a[1] b.y – m;
/* ———- keeping the camera over the map: no horizon, no empty page past the map’s edges ———- */
function groundAt(c, sx, sy) { // where a screen point lands on the map (null above the horizon)
const sp = Math.sin(c.pitch), cp = Math.cos(c.pitch), x = (sx – c.ox) / FOCAL, y = -(sy – c.oy) / FOCAL;
const den = cp – y * sp;
if (den B.x1 – B.x0 || f.y1 – f.y0 > B.y1 – B.y0) return null;
return [f.x0 B.x1 ? B.x1 – f.x1 : 0, f.y0 B.y1 ? B.y1 – f.y1 : 0];
}
const maxPitch = (c) => Math.max(0, CAMERA.maxRay * RAD – Math.atan(c.oy / FOCAL)); // keeps the horizon out of view
function fitView(c0) {
// Near the map’s edges the camera first looks a little more steeply down, then slides a little, then
// (rarely) moves in, so the screen is always covered by map.
if (FLAT) return c0;
const B = L.bounds, c = { …c0 };
c.pitch = Math.min(c.pitch, maxPitch(c));
const slideCap = .2 * Math.min(L.W, L.H);
const ok = (p) => { const s = shiftInto(footprint({ …c, pitch: p }), B); return !!s && Math.hypot(s[0], s[1]) * FOCAL / c.D 0 && !ok(c.pitch)) {
let lo = Math.min(c.pitch, CAMERA.minTilt * RAD), hi = c.pitch;
if (ok(lo)) { for (let i = 0; i < 12; i++) { const m = (lo + hi) / 2; if (ok(m)) lo = m; else hi = m; } }
c.pitch = lo;
}
let s = shiftInto(footprint(c), B);
for (let i = 0; i (w >= 1 ? fitView(m) : w <= 0 ? m : mixCam(m, fitView(m), w));
// How far back the chase camera can sit along the route: as far as it goes while the screen stays on the map.
// Worked out once per screen size, then eased along the route so the zoom changes gently, never in jumps.
function zoomProfile() {
// At each point along the route: the furthest the camera can pull back while the screen stays on the map,
// easing its tilt (not below CAMERA.chaseMinTilt) where that lets it see further. Worked out once per screen
// size, then eased along the route so zoom and tilt change gently, never in jumps.
const step = 20, d0 = -L.busGap, rawD = [], rawP = [];
const near = FOCAL / (L.sF * CAMERA.zoomIn), far = FOCAL / (L.sF * (L.wide ? CAMERA.zoomOut : CAMERA.phoneZoomOut));
for (let d = d0; d { const s = shiftInto(footprint({ …c, D, pitch: P }), L.bounds); return !!s && Math.hypot(s[0], s[1]) * FOCAL / D { // the steepest-into-the-distance tilt that fits at D, or -1
if (fitsAt(D, top)) return top;
if (!fitsAt(D, low)) return -1;
let lo = low, hi = top;
for (let i = 0; i = 0) lo = far;
else {
P = bestPitch(near);
if (P >= 0) for (let i = 0; i = 0) { lo = m; P = q; } else hi = m; }
else P = low;
}
rawD.push(lo); rawP.push(P);
}
const W1 = 8, W2 = 5, n = rawD.length; // tightest spot nearby, then a soft average
const soften = (raw) => { const mn = raw.map((_, i) => Math.min(…raw.slice(Math.max(0, i – W1), i + W1 + 1)));
return mn.map((_, i) => { const a = mn.slice(Math.max(0, i – W2), Math.min(n, i + W2 + 1)); return a.reduce((x, y) => x + y, 0) / a.length; }); };
L.zoom = { d0, step, D: soften(rawD), P: soften(rawP) };
}
function chaseAt(d) { // { D, pitch } for the chase camera at d
const Z = L.zoom, f = clamp((d – Z.d0) / Z.step, 0, Z.D.length – 1), i = Math.floor(f), j = Math.min(i + 1, Z.D.length – 1);
return { D: lerp(Z.D[i], Z.D[j], f – i), pitch: lerp(Z.P[i], Z.P[j], f – i) };
}
/* ———- things that stand up on the map, drawn as flat pages placed in 3D ———- */
function quadMatrix(p, w, h) { // CSS matrix3d that maps a w x h element onto the screen quad p (TL, TR, BR, BL)
const [x0, y0] = p[0], [x1, y1] = p[1], [x2, y2] = p[2], [x3, y3] = p[3];
const dx1 = x1 – x2, dx2 = x3 – x2, dy1 = y1 – y2, dy2 = y3 – y2, sx = x0 – x1 + x2 – x3, sy = y0 – y1 + y2 – y3;
let g = 0, k = 0;
if (Math.abs(sx) > 1e-9 || Math.abs(sy) > 1e-9) { const det = dx1 * dy2 – dx2 * dy1; g = (sx * dy2 – dx2 * sy) / det; k = (dx1 * sy – sx * dy1) / det; }
const a = (x1 – x0 + g * x1) / w, b = (x3 – x0 + k * x3) / h, d = (y1 – y0 + g * y1) / w, e = (y3 – y0 + k * y3) / h;
g /= w; k /= h;
return ‘matrix3d(‘ + [a, d, 0, g, b, e, 0, k, 0, 0, 1, 0, x0, y0, 0, 1].map((v) => +v.toFixed(7)).join(‘,’) + ‘)’;
}
function standing(base, c, width, height, roll = 0, face = .45) { // world corners (TL, TR, BR, BL) of a page standing at base
// Its axes are a blend of “upright, turned toward the camera” and “square to the screen” (face): planted on the
// ground, but without the hard skew upright boards get toward the edges of a view that looks down.
if (FLAT) face = 1;
const sp = Math.sin(c.pitch), cp = Math.cos(c.pitch), fx = Math.cos(c.yaw), fy = Math.sin(c.yaw);
const camX = c.x – fx * c.D * sp, camY = c.y – fy * c.D * sp;
const dist = Math.hypot(base[0] – camX, base[1] – camY) || 1, ax = (base[0] – camX) / dist, ay = (base[1] – camY) / dist;
const norm = (v) => { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; };
const right = norm([lerp(-ay, -fy, face), lerp(ax, fx, face), 0]);
const up = norm([fx * cp * face, fy * cp * face, lerp(1, sp, face)]);
const cr = Math.cos(roll), sr = Math.sin(roll);
return [[-.5, 1], [.5, 1], [.5, 0], [-.5, 0]].map(([u, v]) => {
const x = u * width, h = v * height, xr = x * cr – h * sr, hr = x * sr + h * cr;
return [base[0] + right[0] * xr + up[0] * hr, base[1] + right[1] * xr + up[1] * hr, right[2] * xr + up[2] * hr];
});
}
const projQuad = (V, Q) => { const out = []; for (const q of Q) { const p = V.project(q[0], q[1], q[2]); if (!p) return null; out.push(p); } return out; };
const quadBox = (P) => [Math.min(…P.map((p) => p[0])), Math.min(…P.map((p) => p[1])), Math.max(…P.map((p) => p[0])), Math.max(…P.map((p) => p[1]))];
const overlaps = (a, b, m = 0) => a[0] b[0] – m && a[1] b[1] – m;
const rectBox = (r) => [r.x, r.y, r.x + r.w, r.y + r.h];
/* ———- stop signs: grow straight up out of the stop circle as the bus pulls in ———- */
const signsEl = $(‘#signs’);
const SIGNS = STATIONS.map((S, i) => {
const el = document.createElement(‘div’);
el.className = ‘sign’;
el.innerHTML = ‘Bus stop ‘ + pad2(i + 1) + ‘‘;
el.querySelector(‘b’).textContent = S.name;
signsEl.appendChild(el);
return { el, r: { x: 0, v: 0 }, roll: { x: 0, v: 0 }, w: 0, h: 0, shown: false, css: ” };
});
function sizeSigns() { // the sign’s own size in px; its size on the map comes from the stop view
SIGNS.forEach((g) => { g.el.style.transform = ‘none’; g.w = g.el.offsetWidth; g.h = g.el.offsetHeight; });
}
function signPlace(i, c) { // stop i’s sign stands on its circle; sized from the stop view c
const S = STATIONS[i], V = view(c, L.W, L.H), P = V.project(S.x, S.y), ppu = P ? V.F / P[2] : 1;
const width = Math.min(SIGNS[i].w * (L.wide ? clamp(L.W / 1440 * .9, .68, 1) : .6), L.W * (L.wide ? .2 : .4)) / ppu; // same text size on every sign
return { base: [S.x, S.y], width, height: width * SIGNS[i].h / SIGNS[i].w };
}
function updateSigns(now, dt, V) {
const chase = V.flat > .55 && !navMode && !(peek && peek.phase === ‘out’);
// One sign at a time: the stop the bus is pulling into (or waiting at) owns the sign. Where two stops sit close
// together, the next sign only springs up once the last one has sunk, so they never overlap.
const reach = Math.max(70, L.busLen1 / L.sF * 2.4);
let owner = -1, best = 1e9;
if (chase) for (let i = 0; i -reach && rel < 6 && Math.abs(rel) SIGNS.every((o, j) => j === i || o.r.x < .3);
for (let i = 0; i .3 || othersDown(i)) ? 1 : 0;
if (motion.matches) { g.r.x = goal; g.r.v = 0; g.roll.x = 0; }
else {
const k = goal ? 170 : 260, damp = goal ? 11 : 26;
g.r.v += (k * (goal – g.r.x) – damp * g.r.v) * dt; g.r.x += g.r.v * dt;
g.roll.v += (-150 * g.roll.x – 7 * g.roll.v – g.r.v * 1.6) * dt; g.roll.x += g.roll.v * dt; // sways as it shoots up
}
const r = Math.max(0, g.r.x);
if (r p.d r.x >= 12 && r.x + r.w = AR.top && r.y + r.h <= AR.bottom;
const OFF = [0, -.08, .08, -.16, .16, -.26, .26];
let best = null;
for (const size of [1, .86, .74]) for (const a of AR.list) for (const ox of OFF) for (const oy of OFF) {
const ax = (a.ideal[0] + ox) * W, ay = (a.ideal[1] + oy) * H;
if (ax W – 20 || ay AR.bottom – 10) continue;
// the camera puts the stop at the anchor, unless the map edge stops it; frames follow the real spot
const tx = clamp(ax – S.x * s, lo[0], 0), ty = clamp(ay – S.y * s, lo[1], 0), q = [S.x * s + tx, S.y * s + ty];
let mw = AR.mw0 * size, r = null, shrink = 0; // smaller frames only when that is what keeps the line clear
for (; shrink < 7; shrink++, mw *= .93) {
r = a.place(boxRect(0, 0, mw), boxRect(0, 0, mw * AR.ratio), q[0], q[1]);
if (fits(r.main) && fits(r.secondary)) break;
}
if (shrink === 7) continue;
const lab = a.side === 'right'
? [q[0] – mkR, q[1] – labH / 2, q[0] + mkR + 8 + labW, q[1] + labH / 2]
: [q[0] – mkR – 8 – labW, q[1] – labH / 2, q[0] + mkR, q[1] + labH / 2];
if (lab[0] < 8 || lab[1] W – 8 || lab[3] > AR.bottom || hits(lab, r.main, 12) || hits(lab, r.secondary, 12)) continue;
let covered = 0; // keep the frames off the line already drawn
for (const p of drawn) {
const x = p[0] * s + tx, y = p[1] * s + ty;
for (const b of [r.main, r.secondary]) if (x > b.x – 10 && x b.y – 10 && y < b.y + b.h + 10) { covered++; break; }
}
const cost = covered * 40 + (a.name === want ? 0 : 45) + (Math.abs(ox) + Math.abs(oy)) * 60 + shrink * 14 + (1 – size) * 320;
if (!best || cost < best.cost) best = { cost, q, tx, ty, v: { name: a.name, side: a.side, main: r.main, secondary: r.secondary } };
}
if (!best) { // nothing fits cleanly: centre the stop, first arrangement
const tx = clamp(W / 2 – S.x * s, lo[0], 0), ty = clamp(H / 2 – S.y * s, lo[1], 0), q = [S.x * s + tx, S.y * s + ty];
const r = AR.list[0].place(boxRect(0, 0, AR.mw0 * .7), boxRect(0, 0, AR.mw0 * .7 * AR.ratio), q[0], q[1]);
best = { q, tx, ty, v: { name: AR.list[0].name, side: AR.list[0].side, main: r.main, secondary: r.secondary } };
}
const c2 = { s, tx: best.tx, ty: best.ty };
return { cam2: c2, cam: { x: S.x, y: S.y, D: FOCAL / s, pitch: 0, yaw: NORTH, ox: best.q[0], oy: best.q[1] },
q: best.q, variant: best.v, side: best.v.side, slack: slackOf(c2, W, H) };
}
// The chase view while scrolling: the camera is fixed behind the bus, so the frames are fitted around where
// the stop lands, clear of the bus, the stop sign and the red line already drawn.
function solveChase(S, AR, k, want) {
const c = L.st[k].cam, V = view(c, L.W, L.H), W = L.W;
const q = V.project(S.x, S.y) || [c.ox, c.oy];
const size = lerp(L.busLen0 / L.over.s, L.busLen1 / L.sF, zoomOf(c)), db = STOP_AT[k] – L.busGap, bp = pointAt(db);
const th = headingAt(db), bf = [Math.cos(th), Math.sin(th)], br = [-bf[1], bf[0]], pts = [];
for (const u of [-1.1, 1.1]) for (const v of [-1.2, 1.2]) for (const h of [0, 1.1]) {
const p = V.lift(bp[0] + bf[0] * u * size * BUS_LENGTH / 2 + br[0] * v * size * .19, bp[1] + bf[1] * u * size * BUS_LENGTH / 2 + br[1] * v * size * .19, h * size * .3);
if (p) pts.push(p);
}
const busBox = pts.length ? quadBox(pts) : [q[0] – 30, q[1] – 30, q[0] + 30, q[1] + 30];
const SP = projQuad(V, standing(L.st[k].sign.base, c, L.st[k].sign.width, L.st[k].sign.height));
const signBox = SP ? quadBox(SP) : [q[0] – 40, q[1] – 80, q[0] + 40, q[1]];
const mk = [q[0] – 18, q[1] – 18, q[0] + 18, q[1] + 18];
const drawn = [];
for (const p of DENSE) if (p.d r.x >= 12 && r.x + r.w = AR.top && r.y + r.h <= AR.bottom;
let best = null;
const sw = (signBox[2] – signBox[0]) / 2 + 14, sh = q[1] – signBox[1];
const shifts = [[0, 0], [-sw, 0], [sw, 0], [-sw – 60, 0], [sw + 60, 0], [0, -sh * .5], [-sw, -sh * .5], [sw, -sh * .5], [0, -sh], [-sw, sh * .3], [sw, sh * .3]];
for (const size of [1, .86, .74, .64]) for (const a of AR.list) for (const [dx, dy] of shifts) {
let mw = AR.mw0 * size, r = null, shrink = 0;
for (; shrink < 7; shrink++, mw *= .93) {
r = a.place(boxRect(0, 0, mw), boxRect(0, 0, mw * AR.ratio), q[0] + dx, q[1] + dy);
if (fits(r.main) && fits(r.secondary)) break;
}
if (shrink === 7) continue;
const m = rectBox(r.main), sc = rectBox(r.secondary);
const clash = overlaps(m, signBox, 10) + overlaps(sc, signBox, 10) + overlaps(m, busBox, 8) + overlaps(sc, busBox, 8) + overlaps(m, mk) + overlaps(sc, mk);
let covered = 0;
for (const p of drawn) if (overlaps([p[0], p[1], p[0], p[1]], m, 10) || overlaps([p[0], p[1], p[0], p[1]], sc, 10)) covered++;
const cost = clash * 600 + covered * 40 + (a.name === want ? 0 : 45) + shrink * 14 + (1 – size) * 320 + (Math.abs(dx) + Math.abs(dy)) * .15;
if (!best || cost < best.cost) best = { cost, v: { name: a.name, side: a.side, main: r.main, secondary: r.secondary } };
}
if (!best) {
const r = AR.list[0].place(boxRect(0, 0, AR.mw0 * .6), boxRect(0, 0, AR.mw0 * .6 * AR.ratio), q[0], q[1]);
best = { v: { name: AR.list[0].name, side: AR.list[0].side, main: r.main, secondary: r.secondary } };
}
return { q, variant: best.v };
}
function placeOverLabels(over, W, H, navRes, dOver, elSize) {
// Pick a side for each opening label that keeps it off the red line, the stops, the text box and other labels.
const pts = [];
for (let d = 0; d [S.x * over.s + over.tx, S.y * over.s + over.ty]);
const box = { x: intro.offsetLeft, y: intro.offsetTop, w: intro.offsetWidth, h: intro.offsetHeight };
const lh = elSize * 1.62, g = dOver / 2 + 7, taken = [];
const rectOf = (o, side) => {
const [x, y] = stops[o.i];
if (side === ‘right’) return [x + g, y – lh / 2, x + g + o.w, y + lh / 2];
if (side === ‘left’) return [x – g – o.w, y – lh / 2, x – g, y + lh / 2];
if (side === ‘above’) return [x – o.w / 2, y – g – lh, x + o.w / 2, y – g];
return [x – o.w / 2, y + g, x + o.w / 2, y + g + lh];
};
const order = […overLabels].sort((a, b) => (b.end – a.end));
for (const o of order) {
const prefs = o.i === N – 1 ? [‘left’, ‘below’, ‘above’, ‘right’] : o.i === 0 ? [‘right’, ‘left’, ‘below’, ‘above’] : [‘right’, ‘left’, ‘above’, ‘below’];
let best = null;
prefs.forEach((side, k) => {
const r = rectOf(o, side);
let cost = k * .5;
if (r[0] W – 4 || r[1] H – navRes) cost += 100;
if (introAlpha > 0 && r[0] box.x – 6 && r[1] box.y – 6) cost += 60;
for (const p of pts) if (p[0] > r[0] – 3 && p[0] r[1] – 3 && p[1] { if (j !== o.i && p[0] > r[0] – 6 && p[0] r[1] – 6 && p[1] < r[3] + 6) cost += 25; });
for (const t of taken) if (r[0] t[0] – 4 && r[1] t[1] – 4) cost += 80;
if (!best || cost = 1.2;
const navRes = nav.offsetHeight + 24;
const cover = Math.max(W / MAP.w, H / MAP.h);
const sF = wide ? Math.max(2 * W / MAP.w, 1.5 * cover) : Math.max(.8, 1.45 * cover);
const dFocus = wide ? clamp(W / MAP.w * 24, 12, 17) : 12;
const dOver = wide ? clamp(W / MAP.w * 20, 8, 13) : clamp(W / MAP.w * 44, 6, 10);
const flSize = (wide ? clamp(W / MAP.w * 44, 18, 30) : 18) * .75; // stop name when a stop is in focus
const elSize = wide ? clamp(W / MAP.w * 27, 12, 18) : clamp(W / MAP.w * 80, 11, 14);
const AR = arrangements(W, H, navRes, wide);
// Opening view: the whole map where the screen allows it, always the whole route.
measure.font = ‘700 ‘ + elSize + ‘px Raleway, “Avenir Next”, “Segoe UI”, sans-serif’;
overLabels.forEach((o) => { o.w = measure.measureText(STATIONS[o.i].name).width + elSize + 4; });
const numaishW = (overLabels.find((o) => o.i === N – 1) || { w: 80 }).w;
let over = null, panel = false;
if (wide) {
intro.className = ‘intro intro–panel’; intro.style.top = ”;
const padL = numaishW + 34, padR = 44, padT = 40, padB = navRes + 20;
const s = Math.min(cover, (W – padL – padR) / (BB.x1 – BB.x0), (H – padT – padB) / (BB.y1 – BB.y0));
let tx = (padL + W – padR) / 2 – (BB.x0 + BB.x1) / 2 * s, ty = (padT + H – padB) / 2 – (BB.y0 + BB.y1) / 2 * s;
tx = MAP.w * s >= W ? clamp(tx, W – MAP.w * s, 0) : (W – MAP.w * s) / 2;
ty = MAP.h * s >= H ? clamp(ty, H – MAP.h * s, 0) : (H – MAP.h * s) / 2;
over = { s, tx, ty };
const r = { x: intro.offsetLeft, y: intro.offsetTop, w: intro.offsetWidth, h: intro.offsetHeight };
const m = dOver + 12;
const clear = (o) => !ALL.some((p) => { const x = p[0] * o.s + o.tx, y = p[1] * o.s + o.ty; return x > r.x – m && x r.y – m && y < r.y + r.h + m; });
panel = clear(over) && r.y + r.h < H – navRes;
if (!panel && r.y + r.h = W ? clamp(tx2, W – MAP.w * s2, 0) : (W – MAP.w * s2) / 2;
ty2 = MAP.h * s2 >= H ? clamp(ty2, H – MAP.h * s2, 0) : (H – MAP.h * s2) / 2;
const o2 = { s: s2, tx: tx2, ty: ty2 };
if (s2 > s * .4) { over = o2; panel = true; } // wide screens always keep the side panel
}
}
const phone = !wide;
if (phone) loadPhoneMap();
if (!panel && phone) {
// Phones: the portrait phone map fills the screen; the route sits between the text and the stop bar.
intro.className = ‘intro intro–stack on-map’;
intro.style.top = ‘0px’;
const P = PHONE_MAP, px = P.x, py = P.y, pw = P.w * P.scale, ph = P.h * P.scale;
const ih = intro.offsetHeight;
const s = Math.max(W / pw, H / ph);
let tx = W / 2 – (BB.x0 + BB.x1) / 2 * s;
tx = clamp(tx, W – (px + pw) * s, -px * s);
let ty = (ih + 8 + H – navRes – 14) / 2 – (BB.y0 + BB.y1) / 2 * s;
ty = clamp(ty, H – (py + ph) * s, -py * s);
over = { s, tx, ty };
} else if (!panel) {
intro.className = ‘intro intro–stack’;
intro.style.top = ‘0px’;
const ih = intro.offsetHeight, gap = 18, room = H – navRes – 6;
const s = Math.min(W / MAP.w, Math.max(120, room – ih – gap) / MAP.h);
const off = Math.max(0, (room – ih – gap – MAP.h * s) * .4); // text and map sit together as one block
intro.style.top = off + ‘px’;
over = { s, tx: (W – MAP.w * s) / 2, ty: off + ih + gap };
}
const overSlack = slackOf(over, W, H);
outro.className = intro.className; outro.style.top = intro.style.top; // closing box sits where the opening box sits
placeOverLabels(over, W, H, navRes, dOver, elSize);
pr = Math.min(window.devicePixelRatio || 1, 3);
const pxCap = GL ? 6e6 : 9e6;
if (W * H * pr * pr > pxCap) pr = Math.sqrt(pxCap / (W * H)); // stay well inside phone canvas limits
const cw = Math.round(W * pr), chh = Math.round(H * pr);
if (canvas.width !== cw || canvas.height !== chh) { canvas.width = cw; canvas.height = chh; }
mapDirty = true;
L = { W, H, wide, phone, navRes, cover, sF, dFocus, dOver, flSize, elSize, st: [], over, overSlack,
strokeOver: wide ? clamp(W / MAP.w * 5.2, 2.4, 4) : 2.2, strokeFocus: wide ? clamp(W / MAP.w * 8, 4, 6) : 3.6 };
stage.style.setProperty(‘–el’, elSize + ‘px’);
L.busLen1 = wide ? clamp(W / MAP.w * 64, 30, 46) : 30; // bus length on screen when a stop is in focus
L.busLen0 = wide ? clamp(W / MAP.w * 30, 15, 22) : 13; // … and on the full map
L.chaseY = wide ? .68 : .64; // where the bus sits on screen while the camera follows it
L.overCam = flatCam(over); L.startCam = flatCam(over, STATIONS[0]); L.endCam = flatCam(over, STATIONS[N – 1]);
const PB = PHONE_MAP; // the ground the camera may show: the map (phones: the phone map)
L.bounds = phone ? { x0: PB.x + 2, y0: PB.y + 2, x1: PB.x + PB.w * PB.scale – 2, y1: PB.y + PB.h * PB.scale – 2 } : { x0: 2, y0: 2, x1: MAP.w – 2, y1: MAP.h – 2 };
L.busGap = L.busLen1 / sF * BUS_LENGTH * CAMERA.busGap + 4; // where the bus parks short of a stop, in map units
zoomProfile();
// Two views of each stop: the chase view (scrolling; the bus parked short of the circle, the sign on it)
// and the flat view the stop bar flies to (as before the 3D map).
sizeSigns();
measure.font = ‘800 ‘ + flSize + ‘px Raleway, “Avenir Next”, “Segoe UI”, sans-serif’;
L.st = STATIONS.map((S, i) => {
const pick = FRAME_LAYOUT[S.id] || [‘split-left’, ‘pair-right’, ‘below’, ‘split-right-low’][i % 4];
const flat = solveFlat(S, AR, sF, W, H, measure.measureText(S.name).width + flSize + 4, flSize * 1.7, dFocus * .9 + 3, navRes, wide ? pick : PHONE_LAYOUT[pick], i, wide);
const st = { cam: chaseCam(STOP_AT[i] – L.busGap), flat, side: flat.side, pick: wide ? pick : PHONE_LAYOUT[pick] };
st.sign = signPlace(i, st.cam);
return st;
});
L.st.forEach((st, i) => { st.chase = solveChase(STATIONS[i], AR, i, st.pick); });
measurePuck();
snapBus = true;
// Scroll length. Height only follows big changes, so a phone’s collapsing address bar does not shift the page.
if (W !== lastW || Math.abs(H – lastH) > 140 || !lastH) {
lastW = W; lastH = H;
stepH = Math.max(480, Math.round(H * .9));
story.style.height = Math.round(H + (N + 1.3) * stepH) + ‘px’; // opening, 12 stops, closing view
[…stepsEl.children].forEach((el, i) => { el.style.top = (i + 1) * stepH + ‘px’; el.style.height = stepH + ‘px’; });
storyTop = story.getBoundingClientRect().top + window.scrollY;
if (keepP !== null && keepP > 0 && !jump) window.scrollTo(0, storyTop + keepP * stepH);
} else {
storyTop = story.getBoundingClientRect().top + window.scrollY;
}
if (boxStation >= 0) placeBoxes(boxStation);
snap = true;
}
/* ———- cameras ———- */
const progress = () => clamp((window.scrollY – storyTop) / stepH, 0, N + 1);
const wrapA = (a) => Math.atan2(Math.sin(a), Math.cos(a));
const smooth = (a, b, x) => { const u = clamp((x – a) / (b – a), 0, 1); return u * u * (3 – 2 * u); };
function mixCam(A, B, t) { // in-between camera: positions glide, distance zooms evenly, the turn takes the short way
return { x: lerp(A.x, B.x, t), y: lerp(A.y, B.y, t), D: A.D * Math.pow(B.D / A.D, t), pitch: lerp(A.pitch, B.pitch, t),
yaw: A.yaw + wrapA(B.yaw – A.yaw) * t, ox: lerp(A.ox, B.ox, t), oy: lerp(A.oy, B.oy, t) };
}
function flatCam(o, S) { // the flat full map as a camera, looking at stop S (or the screen centre)
const x = S ? S.x : (L.W / 2 – o.tx) / o.s, y = S ? S.y : (L.H / 2 – o.ty) / o.s;
return { x, y, D: FOCAL / o.s, pitch: 0, yaw: NORTH, ox: x * o.s + o.tx, oy: y * o.s + o.ty };
}
function roadYaw(d) { // the road’s direction around d, averaged so small kinks don’t jolt the camera
const a = pointAt(clamp(d – 35, 0, TOTAL)), b = pointAt(clamp(d + 55, 0, TOTAL));
return Math.atan2(b[1] – a[1], b[0] – a[0]);
}
function chaseCam(d) { // behind the bus, looking down the road, as far back as the map allows
const p = pointAt(d);
const z = chaseAt(d);
return fitView({ x: p[0], y: p[1], D: z.D, pitch: FLAT ? 0 : z.pitch, yaw: roadYaw(d), ox: L.W / 2, oy: L.H * L.chaseY });
}
// The flat stop view used by the stop bar, and the flat flight to it (both as before the 3D map).
const navCam = (k) => (k >= 1 && k (k >= 1 && k ({ x: (L.W / 2 – c.tx) / c.s, y: (L.H / 2 – c.ty) / c.s, D: FOCAL / c.s, pitch: 0, yaw: NORTH, ox: L.W / 2, oy: L.H / 2 });
const to2 = (c) => { const s = FOCAL / c.D; return { s, tx: c.ox – c.x * s, ty: c.oy – c.y * s }; };
function fly2D(A, B, e, k) { // direct flight used when a stop is picked from the bar
const { W, H } = L;
const c0 = k > 0 && k L.over.s * 1.25;
const dip = zoomed ? clamp(Math.hypot(mB[0] – mA[0], mB[1] – mA[1]) * Math.min(A.s, B.s) / Math.max(W, H) * .2, 0, .42) : 0;
let s = A.s * Math.pow(B.s / A.s, e) * (1 – dip * Math.sin(Math.PI * e));
if (zoomed) s = Math.max(s, L.cover * 1.04);
const c = { s, tx: c0[0] – lerp(mA[0], mB[0], e) * s, ty: c0[1] – lerp(mA[1], mB[1], e) * s };
return hold(c, lerpSlack(slackOf(A, W, H), slackOf(B, W, H), e), W, H);
}
function camFromP(p, d) {
const i = Math.min(Math.floor(p), N), f = p – i;
if (i === 0) { // flat map -> swoop down behind the bus at the first stop
const e = ease(clamp(f / T.move1, 0, 1)), m = mixCam(L.startCam, L.st[0].cam, e);
m.pitch = lerp(0, L.st[0].cam.pitch, smooth(.25, 1, e)); // the tilt arrives late, after most of the zoom
return fitted(m, smooth(.05, .45, e));
}
if (i === N) { // last stop -> rise and turn back to the flat full map
const e = ease(clamp((f – T.move0) / (T.outMove1 – T.move0), 0, 1)), m = mixCam(L.st[N – 1].cam, L.endCam, e);
m.pitch = lerp(L.st[N – 1].cam.pitch, 0, smooth(0, .75, e));
return fitted(m, 1 – smooth(.55, .95, e));
}
return chaseCam(d); // from the first stop to the last, the camera stays behind the bus
}
const camOf = (k) => (k === 0 || k === N + 1 ? L.overCam : navMode ? navCam(k) : L.st[k – 1].cam);
function lineFromP(p) { // how much of the red line is drawn
const i = Math.floor(p);
if (i = N) return TOTAL;
return STOP_AT[i – 1] + SEGS[i – 1].len * ease(clamp((p – i – T.line0) / (T.line1 – T.line0), 0, 1));
}
const fullFromP = (p) => (p Math.min(fromFlat ? smooth(0, .35, e) : 1, toFlat ? 1 – smooth(.6, 1, e) : 1);
/* ———- navigation ———- */
let jump = null, peek = null, navMode = false, navBack = null;
function setNavMode(on) { // switching views re-places the frames for the new view
if (on === navMode) return;
navMode = on;
if (boxesOn) setBoxes(false);
boxStation = -1;
}
function goTo(k, flat = false) { // flat: picked from the stop bar or a stop on the map
if (!L) return;
peek = null; navBack = null;
setNavMode(flat);
const y1 = storyTop + k * stepH;
if (motion.matches) { jump = null; window.scrollTo(0, y1); return; }
const p = progress(), fromFlat = cam.pitch < .01 && Math.abs(wrapA(cam.yaw – NORTH)) { if ([‘ArrowUp’, ‘ArrowDown’, ‘PageUp’, ‘PageDown’, ‘Home’, ‘End’, ‘ ‘].includes(e.key)) leaveNav(); });
const cancelJump = () => { jump = null; };
addEventListener(‘wheel’, cancelJump, { passive: true });
addEventListener(‘touchstart’, (e) => { if (!e.target.closest(‘.stop,.mk,.cue,#restart,#fullmap,#soundBtn,#soundMini,#soundMini2’)) cancelJump(); }, { passive: true });
addEventListener(‘keydown’, (e) => { if ([‘ArrowUp’, ‘ArrowDown’, ‘PageUp’, ‘PageDown’, ‘Home’, ‘End’, ‘ ‘].includes(e.key)) cancelJump(); });
const cue = $(‘#cue’), cueText = cue.firstChild;
cue.addEventListener(‘click’, () => (peek && peek.phase === ‘out’ ? peekBack() : goTo(1)));
// Full-map button: zoom out to the whole route from any stop; scrolling (or pressing it again) zooms back in.
/* ———- sound: a looping background, plus a random station clip whenever the bus is at a stop ———- */
const soundBtn = $(‘#soundBtn’);
const SND = { on: false, ctx: null, master: null, bgGain: null, bgSrc: null, buffers: new Map(), voice: null, at: -1, want: -1, wantSince: 0, recent: [], engine: null, engineLevel: 0, rolling: false, brakeAt: 0 };
const audioUrl = (name) => (AUDIO.inline && AUDIO.inline[name]) || AUDIO.dir + name;
function loadBuffer(name) {
if (SND.buffers.has(name)) { const b = SND.buffers.get(name); SND.buffers.delete(name); SND.buffers.set(name, b); return b; } // most recent last
if (SND.buffers.size > 6) { // keep at most a few decoded clips in memory
for (const k of SND.buffers.keys()) { if (k !== AUDIO.background && k !== AUDIO.engine && k !== AUDIO.brakes) { SND.buffers.delete(k); break; } }
}
{
SND.buffers.set(name, fetch(audioUrl(name))
.then((r) => { if (!r.ok) throw new Error(name); return r.arrayBuffer(); })
.then((b) => new Promise((ok, no) => SND.ctx.decodeAudioData(b, ok, no)))
.catch(() => null));
}
return SND.buffers.get(name);
}
function ramp(param, value, secs) {
const t = SND.ctx.currentTime;
param.cancelScheduledValues(t);
param.setValueAtTime(param.value, t);
param.linearRampToValueAtTime(value, t + Math.max(.02, secs));
}
function soundStart() {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
if (!SND.ctx) { // made at load; the browser lets it play from the first tap or click
SND.ctx = new AC();
SND.duck = SND.ctx.createGain(); SND.duck.connect(SND.ctx.destination); // dips the whole soundscape while a video plays
SND.master = SND.ctx.createGain(); SND.master.gain.value = 0; SND.master.connect(SND.duck);
SND.bgGain = SND.ctx.createGain(); SND.bgGain.gain.value = AUDIO.backgroundVolume; SND.bgGain.connect(SND.master);
}
SND.ctx.resume().catch(() => {});
SND.on = true;
ramp(SND.master.gain, 1, .6);
if (!SND.bgSrc) {
SND.bgSrc = ‘loading’;
loadBuffer(AUDIO.background).then((buf) => {
if (!buf) { SND.bgSrc = null; return; }
const src = SND.ctx.createBufferSource();
src.buffer = buf; src.loop = true;
const d = buf.getChannelData(0), sr = buf.sampleRate; // loop between the first and last real samples,
let a = 0, z = d.length – 1; // skipping the silent padding MP3 adds at each end
while (a < Math.min(d.length, sr) && Math.abs(d[a]) d.length – sr && Math.abs(d[z]) {
if (!buf) return;
const src = SND.ctx.createBufferSource();
src.buffer = buf; src.loop = true; src.connect(SND.engine.gain); src.start();
SND.engine.src = src;
});
}
if (AUDIO.brakes) loadBuffer(AUDIO.brakes);
AUDIO.stationClips.slice().sort(() => Math.random() – .5).slice(0, 3).forEach(loadBuffer); // warm up a few
SND.want = -2; // re-evaluate the current stop straight away
}
function soundStop() {
SND.on = false;
if (!SND.ctx) return;
ramp(SND.master.gain, 0, .35);
stopVoice(SND.voice); SND.voice = null; SND.at = -1;
setTimeout(() => { if (!SND.on) SND.ctx.suspend(); }, 450);
}
function pickClip() { // random, but never one of the last few heard
const pool = AUDIO.stationClips.filter((c) => !SND.recent.includes(c));
const pick = (pool.length ? pool : AUDIO.stationClips)[Math.floor(Math.random() * (pool.length || AUDIO.stationClips.length))];
SND.recent.push(pick);
while (SND.recent.length > Math.min(3, AUDIO.stationClips.length – 1)) SND.recent.shift();
return pick;
}
function startVoice() {
const v = { gain: SND.ctx.createGain(), src: null, dead: false, timer: 0 };
v.gain.gain.value = 0; v.gain.connect(SND.master);
loadBuffer(pickClip()).then((buf) => {
if (!buf || v.dead) return;
const src = SND.ctx.createBufferSource();
src.buffer = buf; src.connect(v.gain); src.start();
v.src = src;
ramp(v.gain.gain, AUDIO.stationVolume, AUDIO.fadeIn);
// still parked when the section runs out? cross-fade into another random one
v.timer = setTimeout(() => { if (SND.voice === v) { SND.voice = startVoice(); stopVoice(v); } }, Math.max(1, buf.duration – AUDIO.fadeOut) * 1000);
});
return v;
}
function stopVoice(v) {
if (!v) return;
v.dead = true; clearTimeout(v.timer);
ramp(v.gain.gain, 0, AUDIO.fadeOut);
setTimeout(() => { try { if (v.src) v.src.stop(); } catch (e) {} v.gain.disconnect(); }, AUDIO.fadeOut * 1000 + 120);
}
function playBrakes() {
loadBuffer(AUDIO.brakes).then((buf) => {
if (!buf || !SND.on) return;
const g = SND.ctx.createGain(), src = SND.ctx.createBufferSource();
g.gain.value = AUDIO.brakesVolume; g.connect(SND.master);
src.buffer = buf; src.playbackRate.value = .94 + Math.random() * .12; // a little different each stop
src.connect(g); src.start();
src.onended = () => g.disconnect();
});
}
function soundTick(now) {
if (!SND.on) return;
if (SND.duck) { // a station video is playing: the soundscape steps back so its voice is clear
const playing = boxes.some((b) => !b.video.paused && !b.video.ended && !b.video.muted);
SND.duck.gain.setTargetAtTime(playing ? AUDIO.underVideo : 1, SND.ctx.currentTime, playing ? .15 : .6);
}
if (SND.engine) { // engine follows the bus: silent when parked, up and revving when moving
const m = BM.level, t = SND.ctx.currentTime;
SND.engine.gain.gain.setTargetAtTime(AUDIO.engineVolume * Math.min(1, m * 1.4), t, m > SND.engineLevel ? .12 : .45);
if (SND.engine.src) SND.engine.src.playbackRate.setTargetAtTime(.86 + .3 * m, t, .3);
SND.engineLevel = m;
}
// background level: lower under a station clip, lower still while the bus drives or the brakes hiss
let bg = AUDIO.backgroundVolume * (SND.at !== -1 ? AUDIO.backgroundUnderStation : 1);
bg *= lerp(1, AUDIO.backgroundWhileMoving, clamp(BM.level * 1.5, 0, 1));
if (now – (SND.brakeAt || -1e9) < 3200) bg = Math.min(bg, AUDIO.backgroundVolume * AUDIO.backgroundUnderBrakes);
SND.bgGain.gain.setTargetAtTime(bg, SND.ctx.currentTime, bg Math.abs(lineNow – x) .25) SND.rolling = true;
if (SND.rolling && BM.level 1500) { SND.rolling = false; SND.brakeAt = now; playBrakes(); }
if (BM.level = 1 && k = k – .5 && p 220) { SND.at = want; SND.voice = startVoice(); }
}
const soundMini = $(‘#soundMini’), soundMini2 = $(‘#soundMini2’), sndHint = $(‘#sndHint’);
function soundButtonState() { // both speaker buttons: on when sound is actually playing
const audible = SND.on && SND.ctx && SND.ctx.state === ‘running’, waiting = SND.on && !audible;
const tip = audible ? ‘Mute sound’ : waiting ? ‘Click for audio’ : ‘Turn sound on’;
[soundBtn, soundMini, soundMini2].forEach((b) => { b.setAttribute(‘aria-pressed’, audible ? ‘true’ : ‘false’); b.setAttribute(‘aria-label’, tip); });
soundBtn.dataset.tip = tip; soundMini.title = soundMini2.title = tip;
sndHint.classList.toggle(‘is-gone’, !waiting); // “Click for audio” fades away once sound is playing
}
function soundToggle() {
if (performance.now() – (SND.justUnlocked || -1e9) {}); // not yet audible: this click starts it
else if (SND.on) soundStop(); else soundStart();
soundButtonState();
}
soundBtn.addEventListener(‘click’, soundToggle);
soundMini.addEventListener(‘click’, soundToggle);
soundMini2.addEventListener(‘click’, soundToggle);
// Sound is always on. The page tries to play the moment it loads; where the browser holds sound back
// until the reader’s first tap, click or key press (a rule no page can override), it starts right then.
function unlockSound() {
if (!SND.on || !SND.ctx) return;
if (SND.ctx.state !== ‘running’) { SND.justUnlocked = performance.now(); SND.ctx.resume().then(soundButtonState, () => {}); }
if (SND.ctx.state === ‘running’) UNLOCK.forEach((t) => removeEventListener(t, unlockSound, true));
}
const UNLOCK = [‘pointerdown’, ‘pointerup’, ‘mousedown’, ‘keydown’, ‘touchend’, ‘click’];
UNLOCK.forEach((t) => addEventListener(t, unlockSound, true));
try { soundStart(); } catch (e) {}
if (SND.ctx) {
SND.ctx.onstatechange = soundButtonState;
soundButtonState();
}
document.addEventListener(‘visibilitychange’, () => {
if (!SND.ctx) return;
if (document.hidden) SND.ctx.suspend(); else if (SND.on) SND.ctx.resume().catch(() => {});
});
const fullBtn = $(‘#fullmap’);
function peekOut() {
if (!L || peek) return;
jump = null;
peek = { phase: ‘out’, from: { …cam }, t0: performance.now(), dur: motion.matches ? 1 : 950, y: window.scrollY, full0: fullNow };
if (boxesOn) setBoxes(false);
}
function peekBack() {
if (!peek || peek.phase === ‘in’) return;
peek = { phase: ‘in’, from: { …cam }, t0: performance.now(), dur: motion.matches ? 1 : 900, full0: fullNow, intro0: introAlpha };
}
fullBtn.addEventListener(‘click’, () => (peek && peek.phase !== ‘in’ ? peekBack() : peekOut()));
addEventListener(‘wheel’, () => peekBack(), { passive: true });
addEventListener(‘touchmove’, () => peekBack(), { passive: true });
addEventListener(‘keydown’, (e) => { if ([‘ArrowUp’, ‘ArrowDown’, ‘PageUp’, ‘PageDown’, ‘Home’, ‘End’, ‘ ‘, ‘Escape’].includes(e.key)) peekBack(); });
$(‘#restart’).addEventListener(‘click’, () => goTo(1));
/* ———- drawing ———- */
let cam = { x: 0, y: 0, D: 1000, pitch: 0, yaw: NORTH, ox: 0, oy: 0 }, snap = true, last = 0, lastY = 0, snapBus = true;
let introAlpha = 1, outroAlpha = 0, activeNow = -1, boxesOn = false, lastZ = -1, labelOn = new Array(N).fill(false);
let lineNow = 0, fullNow = 1, lineDrawn = -1, fullShown = -1;
const HIDE = ‘translate(-9999px,-9999px)’;
function draw() { // stops and names sit on the projected map points
const V = view(cam, L.W, L.H), z = zoomOf(cam);
if (Math.abs(z – lastZ) > .002) {
lastZ = z;
stage.style.setProperty(‘–mk’, lerp(L.dOver, L.dFocus, z).toFixed(2) + ‘px’);
stage.style.setProperty(‘–fl’, lerp(Math.min(14, L.flSize), L.flSize, z).toFixed(2) + ‘px’);
}
const d = lerp(L.dOver, L.dFocus, z), pts = [];
for (let i = 0; i -300 && P[0] -300 && P[1] < L.H + 300 ? P : null);
}
for (let i = 0; i {
const P = pts[o.i];
if (!P) { o.el.style.transform = HIDE; return; }
const x = P[0], y = P[1], g = d / 2 + 7;
const at = (a, b, ox, oy) => ‘translate(‘ + a.toFixed(1) + ‘px,’ + b.toFixed(1) + ‘px) translate(‘ + ox + ‘,’ + oy + ‘)’;
o.el.style.transform = o.side === ‘right’ ? at(x + g, y, ‘0’, ‘-50%’) : o.side === ‘left’ ? at(x – g, y, ‘-100%’, ‘-50%’)
: o.side === ‘above’ ? at(x, y – g, ‘-50%’, ‘-100%’) : at(x, y + g, ‘-50%’, ‘0’);
});
}
/* ———- the stop bar’s highlight: one puck that glides from stop to stop with the bus ———- */
const puck = document.createElement(‘span’);
puck.className = ‘puck’; puck.setAttribute(‘aria-hidden’, ‘true’); nav.appendChild(puck);
const PK = { x: null, v: 0, y: 0, s: { x: 1, v: 0 }, cx: [] };
function measurePuck() {
const n = nav.getBoundingClientRect();
PK.cx = dots.map((d) => { const r = d.getBoundingClientRect(); return r.left + r.width / 2 – n.left; });
const r0 = dots[0].getBoundingClientRect(); PK.y = r0.top + r0.height / 2 – n.top;
PK.x = null;
}
function updatePuck(dt, active) {
if (!PK.cx.length || !PK.cx[N – 1]) measurePuck();
let a = 0;
while (a STOP_AT[a + 1]) a++;
const s = clamp((lineNow – STOP_AT[a]) / SEGS[a].len, 0, 1), tx = lerp(PK.cx[a], PK.cx[a + 1], s);
if (PK.x === null || motion.matches) { PK.x = tx; PK.v = 0; PK.s.x = 1; PK.s.v = 0; }
// elastic: the puck rides a bouncy spring, overshoots the stop and settles; its jelly body stretches long
// with speed and then wobbles (squash, stretch, squash) as it lands
const h = Math.min(dt, .034), steps = Math.ceil(dt / h);
for (let i = 0; i = 1 && active x.toFixed(1) + ‘px ‘ + y.toFixed(1) + ‘px’;
switch (style) {
case ‘iris’: {
const near = Math.hypot(G.ox – G.cx, G.oy – G.cy);
const far = Math.max(…[[0, 0], [G.w, 0], [0, G.h], [G.w, G.h]].map(([x, y]) => Math.hypot(G.ox – x, G.oy – y))) + 40;
return { origin: at(G.ox, G.oy), dur: 720, easing: ‘cubic-bezier(.45,0,.2,1)’, frames: [
{ clipPath: ‘circle(‘ + near.toFixed(0) + ‘px at ‘ + at(G.ox, G.oy) + ‘)’, opacity: 1 },
{ clipPath: ‘circle(‘ + far.toFixed(0) + ‘px at ‘ + at(G.ox, G.oy) + ‘)’, opacity: 1 }] };
}
case ‘unfold’:
return { origin: at(G.cx, G.cy), dur: 760, easing: ‘linear’, frames: [
{ transform: ‘scale(.02,.008)’, opacity: 0, borderColor: ‘#EE0608’, easing: ‘cubic-bezier(.3,.7,.3,1)’ },
{ transform: ‘scale(1,.008)’, opacity: 1, borderColor: ‘#EE0608’, offset: .42, easing: ‘cubic-bezier(.25,1.25,.4,1)’ },
{ transform: ‘scale(1,1)’, opacity: 1, borderColor: ‘#4C4A4B’ }] };
case ‘swing’: {
const dx = G.ox – G.w / 2, dy = G.oy – G.h / 2;
const horiz = Math.abs(dx) / G.w >= Math.abs(dy) / G.h;
const origin = horiz ? (dx < 0 ? 'left center' : 'right center') : (dy < 0 ? 'center top' : 'center bottom');
const axis = horiz ? 'rotateY' : 'rotateX', sign = horiz ? (dx < 0 ? -1 : 1) : (dy < 0 ? 1 : -1);
const P = 'perspective(1200px) ';
return { origin, dur: 820, easing: 'linear', frames: [
{ transform: P + axis + '(' + 88 * sign + 'deg)', opacity: 0, easing: 'cubic-bezier(.2,.7,.3,1)' },
{ transform: P + axis + '(' + 50 * sign + 'deg)', opacity: 1, offset: .2, easing: 'cubic-bezier(.3,.6,.35,1)' },
{ transform: P + axis + '(' + -9 * sign + 'deg)', opacity: 1, offset: .68, easing: 'ease-in-out' },
{ transform: P + axis + '(0deg)', opacity: 1 }] };
}
case 'fly': {
const dx = G.ox – G.w / 2, dy = G.oy – G.h / 2, len = Math.hypot(dx, dy) || 1;
const px = -dy / len * 48, py = dx / len * 48, tilt = dx < 0 ? -10 : 10;
return { origin: 'center', dur: 780, easing: 'linear', frames: [
{ transform: 'translate(' + dx.toFixed(0) + 'px,' + dy.toFixed(0) + 'px) scale(.06) rotate(' + tilt + 'deg)', opacity: 0, easing: 'cubic-bezier(.2,.6,.3,1)' },
{ opacity: 1, offset: .12 },
{ transform: 'translate(' + (dx * .2 + px).toFixed(0) + 'px,' + (dy * .2 + py).toFixed(0) + 'px) scale(.9) rotate(' + (-tilt * .3) + 'deg)', opacity: 1, offset: .58, easing: 'cubic-bezier(.3,.5,.3,1)' },
{ transform: 'none', opacity: 1 }] };
}
case 'bloom':
return { origin: at(G.ox, G.oy), dur: 760, easing: 'cubic-bezier(.16,.9,.3,1)', frames: [
{ transform: 'scale(.3)', filter: 'blur(16px)', opacity: 0 },
{ opacity: 1, offset: .3 },
{ transform: 'none', filter: 'blur(0px)', opacity: 1 }] };
case 'elastic':
return { origin: at(G.ox, G.oy), dur: 960, easing: 'linear', frames: [
{ transform: 'scale(.04,.04)', opacity: 0, easing: 'cubic-bezier(.3,.8,.4,1)' },
{ transform: 'scale(1.14,.86)', opacity: 1, offset: .32, easing: 'ease-in-out' },
{ transform: 'scale(.93,1.07)', offset: .52, easing: 'ease-in-out' },
{ transform: 'scale(1.04,.97)', offset: .7, easing: 'ease-in-out' },
{ transform: 'scale(.99,1.01)', offset: .86, easing: 'ease-in-out' },
{ transform: 'scale(1,1)' }] };
default: // 'pop'
return { origin: at(G.ox, G.oy), dur: 620, easing: 'cubic-bezier(.3,1.45,.55,1)', frames: [
{ transform: 'scale(.04)', opacity: 0 }, { opacity: 1, offset: .15 }, { transform: 'none', opacity: 1 }] };
}
}
function showBox(box, delay) {
const el = box.el;
if (box.anim) box.anim.cancel();
if (box.empty) { el.classList.remove('is-on'); return; }
el.classList.add('is-on');
if (motion.matches || !el.animate) { if (el.animate) box.anim = el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 200 }); return; }
const style = popStyleFor(), K = popKeyframes(style, popGeometry(box));
el.style.transformOrigin = K.origin;
box.anim = el.animate(K.frames, { duration: K.dur, easing: K.easing, delay, fill: 'backwards' });
}
function hideBox(box) {
const el = box.el;
if (box.anim) box.anim.cancel();
box.video.pause();
if (!el.classList.contains('is-on')) return;
if (motion.matches || !el.animate || boxStation { if (box.anim === a) { el.classList.remove(‘is-on’); a.cancel(); box.anim = null; } };
}
function setBoxes(on) {
boxesOn = on;
boxes.forEach((b, j) => (on ? showBox(b, j ? (popStyleFor() === ‘unfold’ ? 170 : 110) : 0) : hideBox(b)));
boxes[0].wantAuto = on;
if (on) setTimeout(autoPlayMain, 650); // once the pop has played
}
// The main video starts on its own when the frames pop. Playing the second one pauses it (and the other way round).
// Browsers only allow sound to start by itself after the reader has clicked or tapped the page once; before that the
// main video starts muted and its sound comes on at the first tap.
function autoPlayMain() {
const m = boxes[0];
if (!boxesOn || !m.wantAuto || m.empty || !m.video.getAttribute(‘src’) || boxes.some((b) => !b.video.paused)) return;
if (m.video.readyState { m.video.muted = true; m.video.play().catch(() => {}); });
}
boxes[0].video.addEventListener(‘canplay’, autoPlayMain);
[‘pointerdown’, ‘touchend’, ‘keydown’].forEach((t) => addEventListener(t, () => boxes.forEach((b) => { if (b.video.muted && !b.video.paused) b.video.muted = false; }), true));
function applyUI(ui) {
lineNow = ui.line; fullNow = ui.full;
const canPeek = ui.peek || (ui.active >= 1 && ui.active <= N && !jump);
if (fullBtn.disabled === canPeek) fullBtn.disabled = !canPeek;
if ((fullBtn.getAttribute('aria-pressed') === 'true') !== ui.peek) {
fullBtn.setAttribute('aria-pressed', ui.peek ? 'true' : 'false');
const tip = ui.peek ? 'Back to ' + (STATIONS[clamp(ui.active, 1, N) – 1] || STATIONS[0]).name : 'Show the full map';
fullBtn.dataset.tip = tip; fullBtn.setAttribute('aria-label', tip);
cueText.nodeValue = ui.peek ? tip + ' ' : 'Start at Malir Halt '; // the title box's button returns to the stop
}
if (ui.active !== activeNow) {
activeNow = ui.active;
for (let i = 0; i < N; i++) {
const on = i + 1 === activeNow;
markers[i].classList.toggle('is-active', on);
dots[i].classList.toggle('is-active', on);
dots[i].classList.toggle('is-done', i + 1 = 0 && !(boxesOn && !ui.box)) { fillBoxes(ui.boxIdx); if (boxesOn && ui.box) setBoxes(true); }
if (ui.box !== boxesOn) setBoxes(ui.box);
for (let i = 0; i .004 || ui.intro === 0 || ui.intro === 1) {
introAlpha = ui.intro;
intro.style.opacity = introAlpha.toFixed(3);
intro.style.visibility = introAlpha .02 && !intro.dataset.shown) { intro.dataset.shown = ‘1’; playEntrance(intro); }
if (introAlpha .004 || ui.outro === 0 || ui.outro === 1) {
outroAlpha = ui.outro;
outro.style.opacity = outroAlpha.toFixed(3);
outro.style.visibility = outroAlpha .02 && !outro.dataset.shown) { outro.dataset.shown = ‘1’; playEntrance(outro); }
if (outroAlpha o.el.classList.toggle(‘is-on’, introAlpha > .45 || outroAlpha > .45));
}
function uiFromP(p) {
const k = Math.round(p), near = Math.abs(p – k);
return { active: p = N + .5 ? N + 1 : clamp(k, 1, N),
box: k >= 1 && k T.line1 – 1 + .03 && p – k = .45 && p navMode && Math.abs(p – (i + 1)) {
const t = h.textContent.trim();
h.setAttribute(‘aria-label’, t);
const red = (h.dataset.red || ”).split(/s+/).filter(Boolean); // words shown in the route’s red
h.innerHTML = t.split(/s+/).map((w) => {
const bare = w.replace(/[^w-]+$/, ”), tail = w.slice(bare.length);
const word = red.includes(bare) ? ‘‘ + bare + ‘‘ + tail : w;
return ” + word + ”;
}).join(‘ ‘);
});
function playEntrance(box) {
if (motion.matches || !box.animate) return;
const parts = […box.children];
[box, …parts, …box.querySelectorAll(‘.w’)].forEach((el) => el.getAnimations().forEach((a) => a.cancel()));
const out = { easing: ‘cubic-bezier(.2,.8,.2,1)’, fill: ‘backwards’ };
box.animate([{ clipPath: ‘inset(0 100% 0 0)’ }, { clipPath: ‘inset(0 0% 0 0)’ }], { duration: 560, easing: ‘cubic-bezier(.65,0,.35,1)’ });
let t = 360;
parts.forEach((el) => {
const words = el.querySelectorAll(‘.w’);
if (words.length) { // headline: word by word, rising out of a soft blur
words.forEach((w, i) => w.animate([{ opacity: 0, transform: ‘translateY(.42em)’, filter: ‘blur(5px)’ }, { opacity: 1, transform: ‘none’, filter: ‘blur(0)’ }],
{ …out, duration: 560, delay: t + i * 60 }));
t += words.length * 60 + 140;
} else {
el.animate([{ opacity: 0, transform: ‘translateY(8px)’ }, { opacity: 1, transform: ‘none’ }], { …out, duration: 460, delay: t });
t += 110;
}
});
}
/* ———- the bus: a 3D box at the tip of the red line, turning with the road. It is drawn through the same
camera as the map, so the tilted views show it from behind and above. It sits above the stop circles and
beneath the stop names, so it can pull right into each station. ———- */
const BUS = [‘busShadow’, ‘busF0’, ‘busF1’, ‘busF2’, ‘busF3’, ‘busBumper’, ‘busStripe’, ‘busWheels’, ‘busHubs’, ‘busGlass’, ‘busLamps’, ‘busTail’, ‘busRoof’, ‘busRoofGlass’, ‘busVents’, ‘busBlink’].reduce((o, id) => (o[id] = $(‘#’ + id), o), {});
let busTh = null, busKey = ”, busD = null, busDir = 1;
const BM = { t: 0, d: null, speed: 0, level: 0, phase: 0, wasMoving: false, settle: 0, tf: ”, acc: 0, th: null,
pitch: { x: 0, v: 0 }, roll: { x: 0, v: 0 }, hop: 0, puffAt: 0, puffDist: 0, endArm: false, wasFast: false, ds: null, smokeFront: false };
const puffLayer = $(‘#busPuffs’), busBody = $(‘#busBody’), busG = $(‘#bus’);
// Exhaust: a continuous stream of cartoon smoke clouds. Each puff is pinned to the map where it left the
// exhaust and rises off it, so a moving bus leaves a trail along the road; standing still it idles out a slow trickle.
const PUFFS = [], SVGNS = ‘http://www.w3.org/2000/svg’;
const PUFF_TONES = [[‘#8C9198’, ‘#C3C7CC’, ‘#DEE0E3’], [‘#7C8189’, ‘#A9AEB5’, ‘#C4C8CD’]]; // rim, body, highlight
function puff(now, at, size, life, drift, bubble) {
const g = document.createElementNS(SVGNS, ‘g’), [rim, body, hi] = PUFF_TONES[Math.random() ”;
if (bubble) g.innerHTML = c(0, 0, 1.2, rim) + c(0, 0, 1, body) + c(-.32, -.32, .3, hi);
else {
// a fresh shape every time: 3-7 lobes scattered around a randomly stretched, randomly tilted oval
const R = (lo, hi) => lo + Math.random() * (hi – lo);
const n = 3 + Math.floor(Math.random() * 5), ax = R(.5, 1.05), ay = R(.3, .7), tilt = R(-.6, .6), a0 = R(0, 6.3);
const lobes = [[R(-.15, .15), R(-.1, .1), R(.75, 1.05)]];
for (let k = 1; k < n; k++) {
const t = a0 + k / (n – 1) * 6.28 + R(-.45, .45), px = Math.cos(t) * ax, py = Math.sin(t) * ay;
lobes.push([px * Math.cos(tilt) – py * Math.sin(tilt), px * Math.sin(tilt) + py * Math.cos(tilt), R(.42, .85)]);
}
if (Math.random() q[2] – p[2]).slice(0, 1 + Math.floor(Math.random() * 2));
const creases = lobes.filter(() => Math.random() {
const t = R(-.6, 1.4), c0 = [x + Math.cos(t) * r * .55, y + Math.sin(t) * r * .55], c1 = [x + Math.cos(t + 1.1) * r * .7, y + Math.sin(t + 1.1) * r * .7];
return ‘M’ + c0[0].toFixed(2) + ‘ ‘ + c0[1].toFixed(2) + ‘Q’ + (x + Math.cos(t + .55) * r * .95).toFixed(2) + ‘ ‘ + (y + Math.sin(t + .55) * r * .95).toFixed(2) + ‘ ‘ + c1[0].toFixed(2) + ‘ ‘ + c1[1].toFixed(2);
}).join(”);
const shine = lit.map(([x, y, r]) => {
const s0 = R(3.4, 3.9), s1 = s0 + R(.8, 1.3), rr = r * R(.55, .7);
return ‘M’ + (x + Math.cos(s0) * rr).toFixed(2) + ‘ ‘ + (y + Math.sin(s0) * rr).toFixed(2) + ‘A’ + rr.toFixed(2) + ‘ ‘ + rr.toFixed(2) + ‘ 0 0 1 ‘ + (x + Math.cos(s1) * rr).toFixed(2) + ‘ ‘ + (y + Math.sin(s1) * rr).toFixed(2);
}).join(”);
g.innerHTML = ‘<g transform="scale(' + (Math.random() ‘ +
lobes.map(([x, y, r]) => c(x, y, r + .17, rim)).join(”) + lobes.map(([x, y, r]) => c(x, y, r, body)).join(”) +
(creases ? ” : ”) +
”;
}
puffLayer.appendChild(g);
PUFFS.push({ g, at, size, life, born: now, drift, spin: (Math.random() – .5) * 40 });
while (PUFFS.length > 56) PUFFS.shift().g.remove();
}
function tickPuffs(now, V, size, tail) { // tail: the exhaust; the trail never reaches further back than one bus length
const reach = size * BUS_LENGTH;
for (let i = PUFFS.length – 1; i >= 0; i–) {
const q = PUFFS[i], a = (now – q.born) / q.life;
if (a >= 1) { q.g.remove(); PUFFS.splice(i, 1); continue; }
const e = 1 – Math.pow(1 – a, 2.2);
const x = q.at[0] + q.drift[0] * e * size, y = q.at[1] + q.drift[1] * e * size, h = q.at[2] + q.drift[2] * e * size;
const far = Math.hypot(x – tail[0], y – tail[1], h – tail[2]) / reach; // 0 at the exhaust, 1 one bus length back
const P = V.lift(x, y, h);
if (far >= 1 || !P) { q.g.remove(); PUFFS.splice(i, 1); continue; }
const sc = q.size * size * (.35 + 1 * e + .06 * Math.sin(a * 9)) * V.F / P[2] * (1 – .45 * V.flat); // swells as it drifts; smaller seen up close
const op = Math.min(a .6 ? 1 – (a – .6) / .4 : 1, far > .75 ? (1 – far) / .25 : 1);
q.g.setAttribute(‘transform’, ‘translate(‘ + P[0].toFixed(1) + ‘ ‘ + P[1].toFixed(1) + ‘) rotate(‘ + (q.spin * e).toFixed(1) + ‘) scale(‘ + sc.toFixed(2) + ‘)’);
q.g.setAttribute(‘opacity’, op.toFixed(2));
}
}
function headingAt(d) { // (before Malir Halt the road simply continues straight back)
const a = pointAt(clamp(d – 12, -400, TOTAL)), b = pointAt(clamp(d + 12, -400, TOTAL));
return Math.atan2(b[1] – a[1], b[0] – a[0]);
}
// D bends the body like the reference: fx / rx shift the roof forward / sideways (fractions of the
// body height), nose raises the front and lowers the back, sq squashes or stretches the height.
const STILL = { fx: 0, rx: 0, nose: 0, sq: 0 };
const OFF = [-9999, -9999, 1];
function busGeom(V, bx, by, th, size, D = STILL) { // a box on the map (size in map units), seen through the camera
const len = size * BUS_LENGTH, wd = size * .38, ht = size * .3, f = [Math.cos(th), Math.sin(th)], r = [-f[1], f[0]];
const W3 = (u, v, h = 0) => {
const k = h * ht, up = k * (1 + D.sq) * (1 + D.nose * u);
return [bx + f[0] * u * len / 2 + r[0] * v * wd / 2 + (D.fx * f[0] + D.rx * r[0]) * k,
by + f[1] * u * len / 2 + r[1] * v * wd / 2 + (D.fx * f[1] + D.rx * r[1]) * k, up];
};
const P = (u, v, h = 0) => { const w = W3(u, v, h); return V.lift(w[0], w[1], w[2]) || OFF; };
return { len, wd, ht, f, r, P, W3 };
}
const poly = (pts) => ‘M’ + pts.map((p) => p[0].toFixed(1) + ‘ ‘ + p[1].toFixed(1)).join(‘L’) + ‘Z’;
const area = (pts) => { let s = 0; for (let i = 0; i { const w = G0.W3(u, v); return V.lift(w[0] + size * .05, w[1] + size * .07, 0) || OFF; });
BUS.busShadow.setAttribute(‘d’, poly(sh));
// the four sides: front (+u), right (+v), back (-u), left (-v); only those turned toward the camera are drawn
const faces = [[[1, -1], [1, 1], G.f], [[1, 1], [-1, 1], G.r], [[-1, 1], [-1, -1], [-G.f[0], -G.f[1]]], [[-1, -1], [1, -1], [-G.r[0], -G.r[1]]]];
let glass = ”, wheels = ”, hubs = ”, stripe = ”, bumper = ”, lamps = ”, tail = ”, backShown = false;
faces.forEach(([a, b, n], i) => {
const el = BUS[‘busF’ + i], corners = [P(…a, 0), P(…b, 0), P(…b, 1), P(…a, 1)];
if (area(corners) P(lerp(a[0], b[0], t), lerp(a[1], b[1], t), h);
const quad = (t0, t1, h0, h1) => poly([at(t0, h0), at(t1, h0), at(t1, h1), at(t0, h1)]);
if (i % 2 === 1) { // long sides: windows, a white stripe beneath them, two wheels with hubs
for (const [w0, w1] of [[.12, .27], [.71, .86]]) {
wheels += quad(w0, w1, -.08, .22);
hubs += quad(lerp(w0, w1, .36), lerp(w0, w1, .64), .02, .12);
}
stripe += quad(.02, .98, .3, .38);
const segs = 5;
for (let k = 0; k poly([P(u0, -.42, 1.06), P(u0, .42, 1.06), P(u1, .42, 1.06), P(u1, -.42, 1.06)]);
BUS.busVents.setAttribute(‘d’, vent(.45, .2) + vent(-.05, -.3) + vent(-.5, -.72)); // roof vents, as in the reference
// seen from behind, the smoke comes toward the camera, so it is drawn in front of the body
const front = backShown && V.flat > .5;
if (front !== BM.smokeFront) { BM.smokeFront = front; if (front) busG.appendChild(puffLayer); else busG.insertBefore(puffLayer, busBody); }
}
// The bus eases after the tip of the red line rather than jumping with each scroll step, so it accelerates
// and brakes like a vehicle (and the springy body has real starts and stops to react to). The camera follows it.
function stepBus(now, line, snapIt) {
const dt0 = BM.t ? Math.min(.1, (now – BM.t) / 1000) : .016;
const gapGoal = navMode || (jump && jump.flat) ? 0 : L.busGap; // short of the circle while scrolling, on it in the flat view
BM.gap = BM.gap === undefined || snapIt ? gapGoal : BM.gap + (gapGoal – BM.gap) * (1 – Math.exp(-dt0 / .3));
const target = clamp(line, 0, TOTAL) – BM.gap;
if (BM.ds === null || snapIt || motion.matches || Math.abs(target – BM.ds) * L.sF > L.W * 1.5) BM.ds = target;
else BM.ds += (target – BM.ds) * (1 – Math.exp(-dt0 / .16));
if (Math.abs(target – BM.ds) .05) busDir = d > busD ? 1 : -1;
busD = d;
const th = headingAt(d) + (busDir BM.speed ? .08 : .25)));
const moving = clamp(BM.speed / 260, 0, 1);
BM.level = moving;
// the springy body: pulling away leaves the roof behind and lifts the nose, braking pitches it forward,
// turning rolls it outward; each spring overshoots and wobbles back like the reference
const calm = motion.matches;
const accel = (BM.speed – prevSpeed) / Math.max(dt, .001);
BM.acc += (accel – BM.acc) * (1 – Math.exp(-dt / .06));
const turnRate = BM.th === null ? 0 : Math.atan2(Math.sin(busTh – BM.th), Math.cos(busTh – BM.th)) / Math.max(dt, .001);
BM.th = busTh;
const spring = (S, target, k, c) => { S.v += (k * (target – S.x) – c * S.v) * dt; S.x += S.v * dt; };
spring(BM.pitch, calm ? 0 : clamp(-BM.acc / 5200, -.42, .42), 120, 9);
spring(BM.roll, calm ? 0 : clamp(-turnRate * BM.speed / 2600, -.32, .32), 140, 10);
// the happy hop at the last stop
if (Math.abs(d – (TOTAL – BM.gap)) < 1.5) { if (BM.endArm && BM.speed < 60 && !calm) { BM.hop = now; BM.endArm = false; } }
else if (d < TOTAL – BM.gap – 30) BM.endArm = true; // armed once the bus is on its way to Numaish
const hopT = BM.hop ? (now – BM.hop) / 1000 : 9;
const hop = hopT < .9 ? Math.max(0, Math.sin(Math.min(1, hopT / .42) * Math.PI)) * (hopT = .42 && hopT < .74 ? .35 * Math.sin((hopT – .42) / .32 * Math.PI) : 0) : 0;
const land = hopT .05 && !calm) {
const ahead = headingAt(clamp(d + busDir * 26, 0, TOTAL)) + (busDir .45) blink = { side: turn > 0 ? 1 : -1, on: Math.floor(now / 340) % 2 === 0 };
}
const key = cam.x.toFixed(2) + cam.y.toFixed(2) + cam.D.toFixed(2) + cam.pitch.toFixed(4) + cam.yaw.toFixed(4) + cam.ox.toFixed(1) + cam.oy.toFixed(1) + ‘,’ +
d.toFixed(2) + ‘,’ + busTh.toFixed(3) + ‘,’ + size.toFixed(2) + ‘,’ +
D.fx.toFixed(3) + D.rx.toFixed(3) + D.sq.toFixed(3) + (blink ? blink.side + ” + blink.on : ‘-‘);
if (key !== busKey) { busKey = key; drawBus(V, pt[0], pt[1], busTh, size, D, blink); }
// exhaust: always smoking; more, bigger puffs the faster it goes, a gentle idle trickle at a standstill
const G = busGeom(V, pt[0], pt[1], busTh, size), tail = G.W3(-1.06, .5, .14);
if (!calm) {
BM.puffDist += step;
const every = lerp(380, 45, moving);
if (now – BM.puffAt > every || (moving > .15 && BM.puffDist > L.busLen1 * .13)) {
BM.puffAt = now; BM.puffDist = 0;
const bubble = Math.random() (Math.random() – .5) * .16;
const sz = lerp(.12, .2, moving) * (.8 + Math.random() * .4) * (bubble ? .32 : 1);
puff(now, tail, sz, lerp(2400, 1500, moving) * (.85 + Math.random() * .3),
[-G.f[0] * lerp(.38, .24, moving) + G.r[0] * .25 + j(), -G.f[1] * lerp(.38, .24, moving) + G.r[1] * .25 + j(), lerp(.5, .3, moving) + j()], bubble);
}
}
tickPuffs(now, V, size, tail);
// gentle road bumps: the body rides up and down and rolls slightly; the faster, the more often
BM.phase += step / Math.max(6, L.busLen1 * .45);
if (BM.speed > 80) BM.wasFast = true;
if (BM.wasFast && BM.speed .02 ? ‘translate(0 ‘ + (-lifted).toFixed(2) + ‘) rotate(‘ + roll.toFixed(2) + ‘ ‘ + cx.toFixed(1) + ‘ ‘ + cy.toFixed(1) + ‘)’ : ”;
if (tf !== BM.tf) { BM.tf = tf; busBody.setAttribute(‘transform’, tf); }
BM.wasMoving = moving > .08;
}
function camShift(a, b) { // roughly how many screen pixels the view moved between two cameras
return (Math.abs(a.x – b.x) + Math.abs(a.y – b.y)) * FOCAL / b.D + Math.abs(Math.log(a.D / b.D)) * L.W +
Math.abs(a.pitch – b.pitch) * L.H + Math.abs(wrapA(a.yaw – b.yaw)) * L.W + Math.abs(a.ox – b.ox) + Math.abs(a.oy – b.oy);
}
function frame(now) {
requestAnimationFrame(frame);
if (!L) return;
const dtRaw = (now – last) / 1000 || .016, dt = Math.min(.05, dtRaw); last = now;
let ui;
if (jump) {
const u = clamp((now – jump.t0) / jump.dur, 0, 1), e = ease(u), k = jump.k, jflat = jump.flat;
window.scrollTo(0, lerp(jump.y0, jump.y1, e));
ui = { active: k, box: false, boxIdx: k > 0 && k .3 ? k – 1 : -1, label: (i) => jflat && i + 1 === k && e > .6,
intro: lerp(jump.intro0, k === 0 ? 1 : 0, clamp(e * 1.6 – (k === 0 ? .6 : 0), 0, 1)),
outro: lerp(jump.outro0, k === N + 1 ? 1 : 0, clamp(e * 1.6 – (k === N + 1 ? .6 : 0), 0, 1)),
line: lerp(jump.line0, lineFromP(k), e), full: lerp(jump.full0, fullFromP(k), e) };
stepBus(now, ui.line, snapBus);
if (jump.flat) { // the stop bar: the flat flight, as before the 3D map
const c3 = to3(fly2D(jump.from2, navCam2(k), e, k));
cam = jump.fromFlat ? c3 : mixCam(jump.from, c3, smooth(0, .45, e));
} else cam = fitted(fly(jump.from, jump.to, e), flyFit(jump.fromFlat, k === 0 || k === N + 1, e));
if (u >= 1) { cam = { …jump.to }; jump = null; }
} else if (peek) {
const p = progress(), u = clamp((now – peek.t0) / peek.dur, 0, 1), e = ease(u);
if (peek.phase === ‘out’ && Math.abs(window.scrollY – peek.y) > 2) peekBack(); // any scroll brings the stop back
ui = uiFromP(p);
stepBus(now, ui.line, snapBus);
if (peek.phase === ‘out’) { // the opening screen: title box, stop names, the whole route
cam = fitted(fly(peek.from, L.overCam, e), flyFit(peek.from.pitch false;
ui.full = lerp(peek.full0, 1, e);
ui.intro = clamp(e * 1.4 – .3, 0, 1); ui.outro = 0;
} else { // back to exactly where the scroll position says we are
cam = navMode ? fly(peek.from, navCam(clamp(Math.round(p), 0, N + 1)), e) : fitted(fly(peek.from, camFromP(p, BM.ds), e), flyFit(true, false, e));
ui.box = ui.box && u > .85; ui.label = u > .7 ? ui.label : () => false;
ui.full = lerp(peek.full0, ui.full, e);
ui.intro = lerp(peek.intro0, ui.intro, clamp(e * 1.8, 0, 1)); ui.outro = 0;
if (u >= 1) peek = null;
}
} else if (navBack) { // easing from the flat stop view back into the chase view
const p = progress(), u = clamp((now – navBack.t0) / navBack.dur, 0, 1), e = ease(u);
ui = uiFromP(p);
ui.box = ui.box && u > .85;
stepBus(now, ui.line, snapBus);
cam = fitted(fly(navBack.from, camFromP(p, BM.ds), e), flyFit(true, false, e));
if (u >= 1) navBack = null;
} else {
const p = progress();
ui = uiFromP(p);
stepBus(now, ui.line, snapBus);
const target = navMode ? navCam(clamp(Math.round(p), 0, N + 1)) : camFromP(p, BM.ds);
cam = snap || motion.matches ? target : mixCam(cam, target, 1 – Math.exp(-dt / .1));
snap = false;
}
ui.peek = !!peek && peek.phase === ‘out’;
lineNow = ui.line; fullNow = ui.full;
// Redraw the map only when the camera moved, the line grew or a tile arrived; fetch tiles a few times a second.
frameNo++;
const prev = lastCam || cam, shift = camShift(prev, cam);
moving = !!jump || shift > 2.5;
const y = window.scrollY;
if (Math.abs(y – lastY) > 1) { lastDir = y > lastY ? 1 : -1; lastY = y; }
updateBlur(prev, cam, Math.min(.25, dtRaw));
if (shift > .01 || mapDirty || blur.amt > 0 || lineNow !== lineDrawn || fullNow !== fullShown) {
drawMap(now); lastCam = { …cam }; lineDrawn = lineNow; fullShown = fullNow;
}
if (now – lastPlan > 160) plan(now);
draw();
applyUI(ui);
placeBus(now, snapBus); snapBus = false;
const V = view(cam, L.W, L.H);
updateSigns(now, dt, V);
updatePuck(dt, ui.active);
soundTick(now);
}
/* ———- start ———- */
layout();
const startAt = STATIONS.findIndex((S, i) => location.hash === ‘#’ + S.id || location.hash === ‘#station-‘ + pad2(i + 1));
if (startAt >= 0) window.scrollTo(0, storyTop + (startAt + 1) * stepH);
new ResizeObserver(layout).observe(stage);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(layout);
requestAnimationFrame(frame);
})();
(function() {
var scripts = document.getElementsByTagName(‘script’);
var currentScript = document.currentScript || scripts[scripts.length – 1];
var wrapper = currentScript;
while (wrapper && (!wrapper.classList || !wrapper.classList.contains(‘raw-html-embed-wrapper’))) {
wrapper = wrapper.parentNode;
}
if (!wrapper || wrapper._rawHtmlMounted) return;
wrapper._rawHtmlMounted = true;
var host = wrapper.querySelector(‘.raw-html-host’);
var sourceEl = wrapper.querySelector(‘script.raw-html-source’);
if (!host || !sourceEl) return;
// 1. Upgrade ancestor overflow:hidden / overflow-x:hidden to clip so native position:sticky works
var anc = wrapper;
while (anc && anc !== document) {
if (anc.nodeType === 1) {
var cs = window.getComputedStyle(anc);
if (cs.overflow === ‘hidden’) {
anc.style.overflow = ‘clip’;
} else {
if (cs.overflowX === ‘hidden’) anc.style.overflowX = ‘clip’;
if (cs.overflowY === ‘hidden’) anc.style.overflowY = ‘clip’;
}
}
anc = anc.parentNode;
}
// 2. Parse the raw HTML using the browser’s native C++ DOMParser (inert: scripts don’t run, images don’t decode yet)
var rawHtml = sourceEl.textContent.replace(/<\/script/gi, '4KB) into binary Blob URLs to prevent iOS Safari Jetsam crashes
function b64ToBlobUrl(dataUri) {
try {
var comma = dataUri.indexOf(‘,’);
if (comma === -1) return dataUri;
var mime = (dataUri.slice(5, comma).split(‘;’)[0]) || ‘image/webp’;
var raw = atob(dataUri.slice(comma + 1));
var len = raw.length;
var bytes = new Uint8Array(len);
for (var i = 0; i < len; i++) {
bytes[i] = raw.charCodeAt(i);
}
return URL.createObjectURL(new Blob([bytes], { type: mime }));
} catch (e) {
return dataUri;
}
}
function optimizeImages(root) {
if (!root || !root.querySelectorAll) return;
var imgs = root.querySelectorAll('img[src^="data:image/"], image[href^="data:image/"]');
for (var i = 0; i 4096) {
el.setAttribute(attr, b64ToBlobUrl(val));
}
if (el.tagName === ‘IMG’ && !el.hasAttribute(‘decoding’)) {
el.setAttribute(‘decoding’, ‘async’);
}
}
var tpls = root.querySelectorAll(‘template’);
for (var j = 0; j < tpls.length; j++) {
optimizeImages(tpls[j].content);
}
var allEls = root.querySelectorAll('*');
for (var k = 0; k < allEls.length; k++) {
if (allEls[k].shadowRoot) {
optimizeImages(allEls[k].shadowRoot);
}
}
}
optimizeImages(parsedDoc);
// 4. Extract executable tags before mounting so they don’t run against global document
// We replace each inline script in the DOM with a dummy marker so that DOM traversal
// patterns like `document.querySelectorAll(‘script’)` and `script.previousElementSibling`
// remain intact when the scripts execute.
var executableScripts = [];
var allScripts = Array.prototype.slice.call(parsedDoc.querySelectorAll(‘script’));
for (var i = 0; i < allScripts.length; i++) {
var s = allScripts[i];
var type = (s.getAttribute('type') || '').trim().toLowerCase();
if (!type || type === 'text/javascript' || type === 'application/javascript' || type === 'module') {
executableScripts.push(s);
var marker = parsedDoc.createElement('script');
if (s.id) marker.id = s.id;
marker.type = 'text/plain';
marker.className = 'raw-html-script-marker';
marker.dataset.scriptIndex = String(executableScripts.length – 1);
s.parentNode.replaceChild(marker, s);
s._domMarker = marker;
}
}
// 5. Promote to parent document.head so @font-face declarations load
var links = parsedDoc.querySelectorAll(‘link[rel=”stylesheet”]’);
for (var l = 0; l :host, html and overflow-x:hidden -> overflow-x:clip in tags
var styles = parsedDoc.querySelectorAll(‘style’);
for (var stIdx = 0; stIdx < styles.length; stIdx++) {
styles[stIdx].textContent = styles[stIdx].textContent
.replace(/:rootb/g, ':host, html')
.replace(/overflow-xs*:s*hidden/gi, 'overflow-x:clip');
}
// 7. Mount the real …… tree into the Shadow Root
var shadow = host.attachShadow({ mode: ‘open’ });
var resetStyle = document.createElement(‘style’);
resetStyle.textContent = ‘:host { all: initial; display: block; width: 100%; isolation: isolate; font-family: system-ui, -apple-system, sans-serif; color: #111; box-sizing: border-box; } html, body { display: block; margin: 0; padding: 0; width: 100%; box-sizing: border-box; } head, title, meta { display: none; }’;
shadow.appendChild(resetStyle);
var shadowHtml = parsedDoc.documentElement;
var shadowHead = parsedDoc.head;
var shadowBody = parsedDoc.body;
shadow.appendChild(shadowHtml);
// 8. Smooth scroll for internal #hash anchor clicks inside Shadow DOM
shadow.addEventListener(‘click’, function(e) {
var a = e.target && e.target.closest ? e.target.closest(‘a[href^=”#”]’) : null;
if (!a) return;
var hash = a.getAttribute(‘href’);
if (!hash || hash === ‘#’) return;
var id = decodeURIComponent(hash.slice(1));
var target = shadow.getElementById(id) || shadow.querySelector(hash);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: ‘smooth’, block: ‘start’ });
}
});
// 9. Lightweight document Proxy that routes DOM queries to the Shadow Root
// while leaving window and all native JS constructors 100% untouched.
var activeScript = null;
var shadowDocument = new Proxy(document, {
get: function(target, prop) {
if (prop === ‘getElementById’) {
return function(id) { return shadow.getElementById(id); };
}
if (prop === ‘querySelector’) {
return function(sel) { return shadow.querySelector(sel); };
}
if (prop === ‘querySelectorAll’) {
return function(sel) { return shadow.querySelectorAll(sel); };
}
if (prop === ‘getElementsByClassName’) {
return function(cls) { return shadowHtml.getElementsByClassName(cls); };
}
if (prop === ‘getElementsByTagName’) {
return function(tag) {
var t = String(tag).toLowerCase();
if (t === ‘html’) return [shadowHtml];
if (t === ‘head’) return [shadowHead];
if (t === ‘body’) return [shadowBody];
return shadowHtml.getElementsByTagName(tag);
};
}
if (prop === ‘documentElement’) return shadowHtml;
if (prop === ‘head’) return shadowHead;
if (prop === ‘body’) return shadowBody;
if (prop === ‘currentScript’) return (activeScript && activeScript._domMarker) || activeScript;
if (prop === ‘addEventListener’) {
return function(type, listener, options) {
if (type === ‘DOMContentLoaded’ && document.readyState !== ‘loading’) {
setTimeout(function() { listener.call(shadowDocument, new Event(‘DOMContentLoaded’)); }, 0);
return;
}
return document.addEventListener(type, listener, options);
};
}
var val = target[prop];
return typeof val === ‘function’ ? val.bind(target) : val;
}
});
// 10. Execute scripts sequentially
function runNextScript(idx) {
if (idx >= executableScripts.length) {
// Free inert and payloads after initialization
var cleanup = shadow.querySelectorAll(‘template, script[type=”text/plain”]’);
for (var c = 0; c < cleanup.length; c++) {
cleanup[c].remove();
}
optimizeImages(shadow);
return;
}
var s = executableScripts[idx];
activeScript = s;
var src = s.getAttribute('src');
if (src) {
var ext = document.createElement('script');
ext.src = src;
ext.async = false;
ext.onload = ext.onerror = function() {
runNextScript(idx + 1);
};
shadowHead.appendChild(ext);
return;
}
try {
var fn = new Function('document', s.textContent);
fn.call(window, shadowDocument);
} catch (err) {
console.error('RawHtml script error:', err);
}
runNextScript(idx + 1);
}
runNextScript(0);
})();
.template__main {
width: 100vw;
padding: 0;
}
.template–hero .template__main {
margin-bottom: 0;
padding-bottom: 0;
}
.story-header-container,
.story__content > :not(.raw-html-embed-wrapper):not(raw-html)
{
max-width: 700px;
margin-left: auto;
margin-right: auto;
padding: 0 20px;
}
.raw-html-embed-wrapper {
margin-bottom: 30px;
}
.template__footer {
padding: 0 20px;
}
.media–right.media–embed .media__item div {
margin: 0 40px !important;
}
.template__header, .prism__footer, .story__title, .story__excerpt, .story__byline,
.story-header-container, header {
display: none;
}
.template__footer {
xdisplay: none;
}
Few things test Karachi commuters’ patience like its roads and public transport. For nearly five years, construction of the Red Line has added another obstacle to daily journeys, with traffic diversions, dust and disrupted businesses along the corridor.
We wanted to understand why the project was taking so long. So we travelled the route, documented the work on the ground and spoke to the people living and working beside it.
The idea for the Red Line was born in the early 2010s when it was clear Karachi could not survive its own traffic. The Japan International Cooperation Agency, known globally as the sensei of city surveys, was asked to help. It counted the cars and motorcycles and studied the madness before coming up with a simple plan: Make six Bus Rapid Transit corridors.
BRT, as it is called, is a short form for giving buses their own lane in the middle of the road where no other vehicles can enter. This allows the bus to zoom people up and down a road without being hemmed in by pesky lane-violators.
One of the six corridors, the Red Line, ran from Numaish near Quaid-e-Azam’s Mazar to Malir Halt, a little further after the Karachi airport. This 26.6km strip is Karachi’s busiest and includes University Road. The planners gauged that it would need over 260 electric buses for it. “We found that more than 60 per cent of regular commuters cross Numaish every day,” said Zubair Channa, CEO of TransKarachi, a public sector company in charge of the project.
Back in 2011, that looked like up to 166,000 vehicles a day at the busiest intersections surveyed on the corridor. Roughly half to two-thirds of them are motorbikes and rickshaws, with just 5pc buses.
Project timeline
The Red Line work began in March 2022 and is less than half done more than four years later. Documents of the Asian Development Bank (ADB), a major financier, note April 30, 2027, as the loan closing date.
According to the Asian Development Bank’s project data sheet, the four financiers had disbursed approximately $128.93 million by August 20, 2026. ADB had disbursed $87.63m of its $235m loan, while the French Development Agency and the Asian Infrastructure Investment Bank had disbursed $21.38m and $19.92m, respectively, out of $71.8m each. No disbursements had been recorded against the Green Climate Fund’s combined $49m grant and loan commitments.
For a project of such scale, two years of inconvenience is a given, said Channa. TransKarachi estimates the corridor work will be done by December 20, 2026.
It said other components of the project will be completed progressively. The Malir Halt Depot is targeted for completion by April 2027, while the Bagh-e-Karachi Depot is expected to be completed by July 2027. The remaining structural and station works are targeted for completion by June 2028.
The Red Line has been divided into two lots or batches. The first is a 9.4km stretch from Malir Halt to Mosamiyat with six stations and two bus depots (at Malir Halt and Bagh-e-Karachi). The contract was awarded to Zahir Khan & Brothers (ZKB) for Rs13.8 billion. The company’s key projects include Swat Motorway, Lahore Eastern Bypass, and New Jam Sadiq Bridge in Karachi. The company was also previously involved in the construction of mass transit, such as the Islamabad-Rawalpindi and Multan Metro Bus systems and the Lahore Orange Line Metro and depot. ZKB began work on Red Line’s Lot-1 on May 10, 2022 and had an initial completion deadline of May 9, 2024, which was later extended to June 30, 2026.
The second lot covers a 12.85-kilometre stretch with 16 stations and was awarded to China Railway No. 3 Engineering Group-AM Associates (joint venture) on January 4, 2022, for Rs15.93bn, and construction commenced on August 15 of the same year. The original completion deadline was August 14, 2024, which was later extended to June 30, 2026. Per its website, AM Associates earlier also worked on the 19.21km Hazara Motorway and the Kohala–Muzaffarabad road for the National Highway Authority.
On April 21, 2026, TransKarachi terminated its contract with China Railway No. 3 Engineering Group–AM Associates for Lot-2, citing slow progress, unsatisfactory performance and violations related to health, safety and the environment. The contractor challenged the decision in the Sindh High Court, which referred the matter to the contract’s Dispute Resolution Board.
The board subsequently declared the termination “unlawful and invalid”, finding that it had been hasty and had failed to adequately account for the project’s complexity. It also identified shortcomings on TransKarachi’s part, including its failure to provide complete designs and an unobstructed work site — factors that contributed to delays. The dispute therefore went beyond disagreements over the contractor’s performance: it also raised questions about the project authority’s own contractual obligations and how the stalled works would be completed.
After the termination, the Sindh cabinet approved off-budget emergency funding and directly engaged the Frontier Works Organisation (FWO) under a government-to-government arrangement to stabilise the corridor and restore essential infrastructure, i.e. mixed traffic lanes, service roads, and drainage along Lot-2. FWO took over the site in May 2026 and is scheduled to complete the assigned works by the end of the year.
The contract for the remaining works—structural works and BRT station works—is in the tendering process and procurement will take place through ADB Open Competitive Bidding.
According to the bank, the method “facilitates open competition among all eligible bidders” through advertising to provide an equal opportunity to as many qualified bidders as possible.
“The lowest bidder gets the contract,” said Rehan. Bids for the replacement Lot-2 structural works had been submitted on October 8 and were being evaluated, with a contract award expected within about a month.
Delays
TransKarachi gave a number of reasons for the delay in the project: post-Covid inflation, land acquisition for bus depots, design revisions, and delays in utility line relocation. There is no justification for it, said Channa, but he reiterated the position that inconvenience caused by a project meant to provide long-term respite to Karachiites was meant to be.
The people directly affected by work on the corridor described the ‘inconvenience’.
“This construction work has been going on for more than 3.5 years. Initially, it was very slow but has picked up speed over the last two months. The dust is the worst part … because of the construction, the traffic jams are bad here. The roadwork has affected our work … there is no work.”
— Stallholder, Safoora Chowrangi
“Before the construction work began, my stall would be crowded with customers. Now, there is barely anyone. I used to set up my stall on the main road previously, but now we are down here.”
— stallholder, Mosamiyat
“A project that was supposed to be completed in two years is going on and on and on with no end in sight. Our business has shut down because they have dug up the road right in front of my shop.”
— shop owner, University Road
“My house is at a 20-25 min distance from here but because of the construction I have been missing my morning classes almost every day. In the evening, our rickshaw is usually stuck in the traffic for an hour or so.”
— student, University of Karachi
“It is like 60pc of my day is spent in traffic.”
— commuter, NIPA
According to TransKarachi, people displaced because of the Red Line construction are compensated. The compensation rates vary according to the type and category, from Rs27,500 to Rs302,500. So far, 131 people have been compensated, with payments made during 2022, 2023, 2024 and 2025. Rehan said the displaced people primarily included vendors operating from extended structures and movable pushcarts.
“No permanent structures were affected by the project,” he added.
TransKarachi also has a Grievance Redress Mechanism in place, where all complaints by commuters, affected communities and other stakeholders along the Red Line corridor are registered. During September 2026, a total of 14 complaints were received, of which the highest were concerning traffic, followed by utility, access, and environment.
Depending on the nature of the complaint, Rehan explained, a Corrective Action Plan is prepared, or an appropriate resolution is provided in coordination with the concerned department, project team, or contractor. The relevant stakeholders undertake the necessary actions to address the issue, and progress is monitored through the GRM portal.
Once the issue has been addressed, the complainant is informed via email, along with corresponding photographs where applicable, and requested to provide feedback on the resolution.The complainant is subsequently given three days to respond. If no response is received within this period, the complaint is closed in accordance with the approved procedure. Any feedback received from the complainant is documented in the GRM records.
The post We toured Karachi's Red Line to bring you this update appeared first on Dawn – Home.
