-
: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 Start at Malir Halt Story by Muzhira Amin and Zareef RindDesign by Mohsin Alam and Ibrahim YahyaVideos 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 Start at Malir Halt Story by Muzhira Amin and Zareef RindDesign by Mohsin Alam and Ibrahim YahyaVideos edited by Wajid Usman Click for audio 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 < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2); const pad2 = (n) => 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] < d) i++; const a = seg.pts[i - 1], b = seg.pts[i], span = (seg.cum[i] - seg.cum[i - 1]) || 1; const u = (d - seg.cum[i - 1]) / span; return [lerp(a[0], b[0], u), lerp(a[1], b[1], u)]; } const ALL = ROUTE.flatMap((pts, i) => (i ? pts.slice(1) : pts)); const ALL_CUM = [0]; for (let i = 1; i < ALL.length; i++) ALL_CUM.push(ALL_CUM[i - 1] + Math.hypot(ALL[i][0] - ALL[i - 1][0], ALL[i][1] - ALL[i - 1][1])); const TOTAL = ALL_CUM[ALL_CUM.length - 1]; const STOP_AT = [0]; // distance along the line to each stop SEGS.forEach((g, i) => STOP_AT.push(STOP_AT[i] + g.len)); function pathTo(len) { // the red line from Malir Halt up to `len` if (len 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] < V.near ? null : [c.ox + F * q[0] / q[2], c.oy - F * q[1] / q[2], q[2]]; }; // Things that stand up off the map (the bus, its smoke). On the flat map they lean up the screen like a // drawing, as the bus always has; as the camera tilts they become true 3D. V.lift = (wx, wy, h) => { 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 < 4; k++) m[k * 4 + r] = rows[r][k]; return m; } /* ---------- base map: a tile pyramid. Near ground gets sharp tiles, distant ground coarse ones ---------- */ const TOP = TILES.levels.length - 1; const tileBound = (i, n, size) => 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.cols; cx++) { const x0 = Math.max(0, tileBound(cx, T.cols, T.w) - TILES.gutter), x1 = Math.min(T.w, tileBound(cx + 1, T.cols, T.w) + TILES.gutter); const y0 = Math.max(0, tileBound(cy, T.rows, T.h) - TILES.gutter), y1 = Math.min(T.h, tileBound(cy + 1, T.rows, T.h) + TILES.gutter); list.push({ lv, cx, cy, url: TILES.inline ? TILES.inline[lv + '-' + cx + '-' + cy] : TILES.dir + lv + '-' + cx + '-' + cy + TILES.ext, x: x0 / T.w * MAP.w, y: y0 / T.h * MAP.h, w: (x1 - x0) / T.w * MAP.w, h: (y1 - y0) / T.h * MAP.h, state: 0, src: null, tex: null, t0: 0, used: 0 }); // state: 0 idle, 1 loading, 2 ready, 3 failed } return list; }); const DENS = TILES.levels.map((T) => 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 < 4; i++) { // clip against the near plane const a = P[i], b = P[(i + 1) % 4], ia = a[2] >= 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 < -30 || x0 > 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 < 4; j++) visit(lv + 1, cx * 2 + (j & 1), cy * 2 + (j >> 1)); }; const T0 = TILES.levels[0]; for (let cy = 0; cy < T0.rows; cy++) for (let cx = 0; cx < T0.cols; cx++) visit(0, cx, cy); return all.sort((a, b) => 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 es\n' + vs)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, '#version 300 es\nprecision 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 ({ 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 + b.w + m && a[2] > b.x - m && a[1] < b.y + b.h + m && a[3] > 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 { const s = shiftInto(footprint({ ...c, pitch: p }), B); return !!s && Math.hypot(s[0], s[1]) * FOCAL / c.D < slideCap; }; if (c.pitch > 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 < 18 && !s; i++) { c.D *= .92; s = shiftInto(footprint(c), B); } if (s) { c.x += s[0]; c.y += s[1]; } return c; } const fitted = (m, w) => (w >= 1 ? fitView(m) : w { // 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 < 10; i++) { const m = (lo + hi) / 2; if (fitsAt(D, m)) lo = m; else hi = m; } return lo; }; let lo = near, hi = far, P = bestPitch(far); if (P >= 0) lo = far; else { P = bestPitch(near); if (P >= 0) for (let i = 0; i < 14; i++) { const m = Math.sqrt(lo * hi), q = bestPitch(m); if (q >= 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[2] + m && a[2] > b[0] - m && a[1] < b[3] + m && a[3] > 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 < N; i++) { const rel = BM.ds - (STOP_AT[i] - L.busGap); // up while the bus pulls in and waits; down as it leaves if (rel > -reach && rel < 6 && Math.abs(rel) < best) { best = Math.abs(rel); owner = i; } } const othersDown = (i) => SIGNS.every((o, j) => j === i || o.r.x < .3); for (let i = 0; i < N; i++) { const g = SIGNS[i], st = L.st[i]; if (!st || !st.sign) continue; const goal = i === owner && (g.r.x > .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 < .02) { if (g.shown) { g.shown = false; g.el.style.visibility = 'hidden'; } continue; } const pl = st.sign, P = projQuad(V, standing(pl.base, cam, pl.width * r, pl.height * r, clamp(g.roll.x, -.5, .5))); if (!P) { g.el.style.visibility = 'hidden'; g.shown = false; continue; } const css = quadMatrix(P, g.w, g.h); if (css !== g.css) { g.css = css; g.el.style.transform = css; } if (!g.shown) { g.shown = true; g.el.style.visibility = 'visible'; } g.el.style.opacity = Math.min(1, r * 3).toFixed(2); const z = String(1000 - Math.round(P[3][2] / cam.D * 100)); // a farther sign always sits behind a nearer one if (g.el.style.zIndex !== z) g.el.style.zIndex = z; } } /* ---------- where the frames sit: two ways, one for each kind of view ---------- */ // The flat stop view used by the stop bar (as before the 3D map): the camera puts the stop where the frames fit. function solveFlat(S, AR, s, W, H, labW, labH, mkR, navRes, want, k, wide) { const busRoom = (wide ? 36 : 26) / s; // the front half of the bus, parked at the stop const drawn = DENSE.filter((p) => p.d r.x >= 12 && r.x + r.w = AR.top && r.y + r.h W - 20 || ay < 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] < 8 || lab[2] > 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.x + b.w + 10 && y > 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 { 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] < 4 || r[2] > W - 4 || r[1] < 4 || r[3] > H - navRes) cost += 100; if (introAlpha > 0 && r[0] < box.x + box.w + 6 && r[2] > box.x - 6 && r[1] < box.y + box.h + 6 && r[3] > box.y - 6) cost += 60; for (const p of pts) if (p[0] > r[0] - 3 && p[0] < r[2] + 3 && p[1] > r[1] - 3 && p[1] < r[3] + 3) cost += 1; stops.forEach((p, j) => { if (j !== o.i && p[0] > r[0] - 6 && p[0] < r[2] + 6 && p[1] > r[1] - 6 && p[1] < r[3] + 6) cost += 25; }); for (const t of taken) if (r[0] < t[2] + 4 && r[2] > t[0] - 4 && r[1] < t[3] + 4 && r[3] > t[1] - 4) cost += 80; if (!best || cost < best.cost) best = { cost, side, r }; }); o.side = best.side; taken.push(best.r); } } function layout() { const W = stage.clientWidth, H = stage.clientHeight; if (!W || !H) return; FOCAL = H / 2 / Math.tan(CAMERA.fov * RAD / 2); const keepP = L ? progress() : null; const wide = W / H >= 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.x + r.w + m && y > r.y - m && y < r.y + r.h + m; }); panel = clear(over) && r.y + r.h < H - navRes; if (!panel && r.y + r.h < H - navRes) { // a tall title panel: fit the route in the room beside it instead const pl = r.x + r.w + numaishW * .35 + 24, s2 = Math.min(cover, (W - pl - padR) / (BB.x1 - BB.x0), (H - padT - padB) / (BB.y1 - BB.y0)); let tx2 = (pl + W - padR) / 2 - (BB.x0 + BB.x1) / 2 * s2, ty2 = (padT + H - padB) / 2 - (BB.y0 + BB.y1) / 2 * s2; tx2 = MAP.w * s2 >= 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 < 1) return 0; 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 < 1 ? 1 - ease(clamp(p / .4, 0, 1)) : 0); // the complete line, shown on the opening map function fly(A, B, e) { // direct flight (stop bar, full-map button): up and over, then down const m = mixCam(A, B, e); const span = Math.hypot(B.x - A.x, B.y - A.y) * FOCAL / Math.max(A.D, B.D) / Math.max(L.W, L.H); const arc = Math.sin(Math.PI * e), dip = clamp(span * .2, 0, .42); m.D /= 1 - dip * arc; m.pitch *= 1 - .55 * arc * clamp(span * 2, 0, 1); return m; } // how firmly a flight is held over the map: fully, except where it starts or ends on the flat full map const flyFit = (fromFlat, toFlat, e) => 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)) < .01; jump = { k, flat, fromFlat, from: { ...cam }, from2: to2(cam), to: camOf(k), y0: window.scrollY, y1, t0: performance.now(), dur: clamp(760 + 150 * Math.abs(k - p), 760, 1700), intro0: introAlpha, outro0: outroAlpha, line0: lineNow, full0: fullNow }; if (boxesOn) setBoxes(false); } function leaveNav() { // the reader scrolls: back from the flat stop view to the chase view if (!navMode || !L) return; jump = null; navBack = { t0: performance.now(), from: { ...cam }, dur: motion.matches ? 1 : 950 }; setNavMode(false); } addEventListener('wheel', leaveNav, { passive: true }); addEventListener('touchmove', leaveNav, { passive: true }); addEventListener('keydown', (e) => { 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]) < 1e-4) a++; while (z > d.length - sr && Math.abs(d[z]) < 1e-4) z--; src.loopStart = a / sr; src.loopEnd = (z + 1) / sr; src.connect(SND.bgGain); src.start(0, src.loopStart); SND.bgSrc = src; }); } if (!SND.engine && AUDIO.engine) { // engine: one looping source; its gain and pitch follow the bus SND.engine = { gain: SND.ctx.createGain(), src: null }; SND.engine.gain.gain.value = 0; SND.engine.gain.connect(SND.master); loadBuffer(AUDIO.engine).then((buf) => { 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 < SND.bgGain.gain.value ? .18 : .7); // brakes: when the bus comes to rest at a station after moving const atStop = STOP_AT.some((x) => Math.abs(lineNow - x) < 1.5); if (BM.level > .25) SND.rolling = true; if (SND.rolling && BM.level < .04 && atStop && now - (SND.brakeAt || 0) > 1500) { SND.rolling = false; SND.brakeAt = now; playBrakes(); } if (BM.level < .04 && !atStop) SND.rolling = false; // a pause between stations is just the reader stopping // the bus is "at" stop k from when it is closing in (half way along the last stretch) until it pulls away let want = -1; if (!jump) { const p = progress(), k = Math.round(p); if (k >= 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) < 900) { soundButtonState(); return; } // this click just started the sound if (SND.on && SND.ctx && SND.ctx.state !== 'running') SND.ctx.resume().then(soundButtonState, () => {}); // 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 < N; i++) { const P = V.project(STATIONS[i].x, STATIONS[i].y); pts.push(P && P[0] > -300 && P[0] < L.W + 300 && P[1] > -300 && P[1] < L.H + 300 ? P : null); } for (let i = 0; i < N; i++) { const P = pts[i]; if (!P) { markers[i].style.transform = HIDE; focusLabels[i].style.transform = HIDE; continue; } const x = P[0], y = P[1], k = clamp(Math.pow(cam.D / P[2], .75), .55, 1.45); // nearer stops a little bigger markers[i].style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) scale(' + k.toFixed(3) + ')'; const g = d * k * (.5 + .4 * z) + 9; focusLabels[i].style.transform = L.st[i].side === 'right' ? 'translate(' + (x + g).toFixed(1) + 'px,' + y.toFixed(1) + 'px) translate(0,-50%)' : 'translate(' + (x - g).toFixed(1) + 'px,' + y.toFixed(1) + 'px) translate(-100%,-50%)'; } overLabels.forEach((o) => { 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 < N - 2 && lineNow > 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 < steps; i++) { PK.v += (190 * (tx - PK.x) - 13 * PK.v) * h; PK.x += PK.v * h; const want = 1 + Math.min(4, Math.abs(PK.v) / 110); // up to five times its width at speed PK.s.v += (520 * (want - PK.s.x) - 9 * PK.s.v) * h; PK.s.x += PK.s.v * h; } const sx = clamp(PK.s.x, .62, 5), sy = clamp(1 / Math.pow(sx, .4), .5, 1.45); puck.style.transform = 'translate(' + PK.x.toFixed(1) + 'px,' + PK.y.toFixed(1) + 'px) scale(' + sx.toFixed(3) + ',' + sy.toFixed(3) + ')'; const on = active >= 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 < 0) { el.classList.remove('is-on'); return; } const G = popGeometry(box); el.style.transformOrigin = G.ox.toFixed(1) + 'px ' + G.oy.toFixed(1) + 'px'; const a = el.animate([{ transform: 'none', opacity: 1 }, { transform: 'scale(.05)', opacity: 0 }], { duration: 260, easing: 'cubic-bezier(.55,0,.75,.2)', fill: 'forwards' }); box.anim = a; a.onfinish = () => { 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 < 2) return; // not ready yet: 'canplay' calls back m.wantAuto = false; m.video.muted = false; m.video.play().catch(() => { 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 { 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() < .4 ? 1 : 0]; const c = (x, y, r, f) => ''; 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() < .35) lobes.push([R(-1.2, 1.2), R(.2, .7), R(.2, .32)]); // a little straggler lobe const lit = lobes.slice().sort((p, q) => q[2] - p[2]).slice(0, 1 + Math.floor(Math.random() * 2)); const creases = lobes.filter(() => Math.random() < .4).slice(0, 2).map(([x, y, r]) => { 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 = '' + 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 < .06 ? a / .06 : 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 < pts.length; i++) { const a = pts[i], b = pts[(i + 1) % pts.length]; s += a[0] * b[1] - b[0] * a[1]; } return s; }; function drawBus(V, bx, by, th, size, D, blink) { const G = busGeom(V, bx, by, th, size, D), P = G.P, G0 = busGeom(V, bx, by, th, size); const sh = [[1.06, -1.1], [1.06, 1.1], [-1.06, 1.1], [-1.06, -1.1]].map(([u, v]) => { 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 < segs; k++) { const t0 = .07 + k * .86 / segs + .012, t1 = .07 + (k + 1) * .86 / segs - .012; glass += quad(t0, t1, .46, .8); } if (blink && blink.side === (i === 1 ? 1 : -1) && blink.on) { // indicator: front and back corner of this side BUS.busBlink.setAttribute('d', quad(0, .07, .28, .4) + quad(.93, 1, .28, .4)); } } else if (i === 0) { // front: windscreen, headlights, bumper glass += quad(.1, .9, .4, .88); lamps += quad(.08, .24, .14, .26) + quad(.76, .92, .14, .26); bumper += quad(0, 1, 0, .1); } else { // back: rear window, tail lights, bumper glass += quad(.2, .8, .56, .84); tail += quad(.06, .2, .16, .3) + quad(.8, .94, .16, .3); bumper += quad(0, 1, 0, .1); } }); if (!blink || !blink.on) BUS.busBlink.setAttribute('d', ''); BUS.busGlass.setAttribute('d', glass); BUS.busWheels.setAttribute('d', wheels); BUS.busHubs.setAttribute('d', hubs); BUS.busStripe.setAttribute('d', stripe); BUS.busBumper.setAttribute('d', bumper); BUS.busLamps.setAttribute('d', lamps); BUS.busTail.setAttribute('d', tail); BUS.busRoof.setAttribute('d', poly([P(1, -1, 1), P(1, 1, 1), P(-1, 1, 1), P(-1, -1, 1)])); BUS.busRoofGlass.setAttribute('d', poly([P(1, -.82, 1), P(1, .82, 1), P(.82, .82, 1), P(.82, -.82, 1)])); // shows which way it faces const vent = (u0, u1) => 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) BM.ds = target; } function placeBus(now, snapIt) { const V = view(cam, L.W, L.H), z = zoomOf(cam); const size = lerp(L.busLen0 / L.over.s, L.busLen1 / L.sF, z); // in map units: larger than life on the full map const d = BM.ds, pt = pointAt(d); // face the way it is travelling: forward along the route, or back toward an earlier stop if (busD !== null && Math.abs(d - busD) > .05) busDir = d > busD ? 1 : -1; busD = d; const th = headingAt(d) + (busDir < 0 ? Math.PI : 0); if (busTh === null || snapIt) busTh = th; else { const dth = Math.atan2(Math.sin(th - busTh), Math.cos(th - busTh)); busTh += dth * .35; } const C = V.lift(pt[0], pt[1], 0) || OFF, cx = C[0], cy = C[1], px = size * V.F / C[2]; // px: its size on screen now // speed along the road, in px per second at the stop view's scale; drives the bounce and the engine sound const dt = BM.t ? Math.min(.1, (now - BM.t) / 1000) : .016; const step = BM.d === null ? 0 : Math.abs(d - BM.d) * L.sF; BM.t = now; BM.d = d; const raw = step / Math.max(dt, .001); const prevSpeed = BM.speed; BM.speed += (raw - BM.speed) * (1 - Math.exp(-dt / (raw > 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 ? 1 : 0) + (hopT >= .42 && hopT < .74 ? .35 * Math.sin((hopT - .42) / .32 * Math.PI) : 0) : 0; const land = hopT < .9 ? Math.exp(-Math.pow((hopT - .42) / .05, 2)) + .6 * Math.exp(-Math.pow((hopT - .74) / .05, 2)) : 0; const D = { fx: BM.pitch.x, rx: BM.roll.x, nose: -BM.pitch.x * .45, sq: -Math.abs(BM.pitch.x) * .12 - land * .22 + hop * .1 }; // indicator: blinks on the side of the next bend, just before the bus takes it let blink = null; if (moving > .05 && !calm) { const ahead = headingAt(clamp(d + busDir * 26, 0, TOTAL)) + (busDir < 0 ? Math.PI : 0); const turn = Math.atan2(Math.sin(ahead - busTh), Math.cos(ahead - busTh)); if (Math.abs(turn) > .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() < .14, j = () => (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 < 25) { BM.settle = now; BM.wasFast = false; } // just stopped: a small dip as it brakes const settle = BM.settle ? Math.max(0, 1 - (now - BM.settle) / 520) : 0; const amp = motion.matches ? 0 : px * .028 * Math.min(1, moving * 1.6); const lift = amp * (Math.abs(Math.sin(BM.phase * Math.PI)) * .8 + .2 * Math.sin(BM.phase * 4.7)) + px * .02 * settle * Math.sin((1 - settle) * 9) * settle; const roll = amp * .9 * Math.sin(BM.phase * 2.3); const lifted = lift + hop * px * .32; const tf = Math.abs(lifted) + Math.abs(roll) > .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 < .01, true, e)); ui.box = false; ui.label = () => 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(/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 < imgs.length; i++) { var el = imgs[i]; var attr = el.hasAttribute('src') ? 'src' : 'href'; var val = el.getAttribute(attr); if (val && val.length > 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 < links.length; l++) { var href = links[l].getAttribute('href'); if (href && !document.head.querySelector('link[href="' + href.replace(/"/g, '\\"') + '"]')) { document.head.appendChild(links[l].cloneNode(true)); } } // 6. Adapt :root -> :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(/:root\b/g, ':host, html') .replace(/overflow-x\s*:\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.
Comment