Spaces:
Running
I already have a working departures board. The data and the order are correct. Do not change what it shows or how it is chosen. Only improve how it looks and moves.
Browse filesMake it feel like a real split-flap board at a station:
• Each character sits on a small flap.
• When a value changes, every flap flips through a short sequence of characters before it lands on the final one.
• The flip is quick, crisp, and mechanical. No blur. No fade.
• Flips are staggered left to right, so a word “rolls” into place.
• Numbers feel slightly faster and snappier than letters.
• Long destinations finish flipping within one second total. Short fields finish faster.
• If a character does not change, it does not flip.
Visual style:
• Dark background. Subtle depth and a hint of grain, like painted metal.
• Flaps are matte charcoal with a thin seam between them.
• Characters are warm yellow, high contrast, sharp edges.
• The icon in the first column sits inside a flap box too, same height and margins as the other flaps.
• Header row shows these titles in yellow: TYPE, TIME, LINE, DESTINATION, PL, STATUS.
• Status text uses the same flaps. Green for “on time” is allowed but stays muted.
Motion details:
• Flap flips make a clean “tick” feeling. One or two ticks per character, not more.
• Start and stop are firm. No bounce. No overshoot.
• When the board refreshes, only rows with changed content flip. Others stay still.
• Rows update without jumping position. No layout shift.
Readability and spacing:
• Generous letter spacing so each flap edge is visible.
• Fixed character widths per column so times and lines align perfectly.
• Destinations truncate at the end if too long, not in the middle.
• Platforms are one or two characters and always centered.
Responsiveness:
• On small screens, reduce the number of visible rows, but keep the same flap proportions and behavior.
• Never shrink text so far that flap seams disappear.
Icons and types:
• Bus, train, metro, tram, ferry each use a simple, single-stroke pictogram.
• Icons match the text color and feel etched into the flap.
• Icon flips in sync with its row when the type changes.
States and edge cases:
• While a row is preparing a change, show a brief idle face of blank flaps, then flip to the new text.
• If data is missing, show dashes on the flaps for that field.
• If there are no departures, show one centered row of blank flaps and a single line that reads “NO DEPARTURES”.
Constraints:
• Keep the current data, order, grouping, and “two per direction per line” rule exactly as they are.
• Do not add new sources or new filters.
• Improve only the look and the movement, not the logic.
Deliver a result that feels like a real mechanical board: crisp, aligned, quiet confidence, fast to read, satisfying to watch.
<!DOCTYPE html>
<html lang="no">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Skøyen Stasjon – Avganger per retning</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
<style>
body { font-family: 'JetBrains Mono', monospace; background: #111; color: #fff; min-height: 100vh; overflow-x: hidden; }
.split-flap{ background:#1a1a1a; color:#ffcc00; border:1px solid #333; border-radius:3px; display:inline-flex; align-items:center; justify-content:center; font-weight:bold; box-shadow:0 1px 2px rgba(0,0,0,.5); overflow:hidden }
.retro-border{ border:4px solid #444; border-radius:8px; box-shadow: inset 0 0 10px #000, 0 0 20px rgba(0,0,0,.5) }
.board-grid{ min-width:800px }
.badge{ font-size:.65rem; padding:.15rem .35rem; border:1px solid #555; border-radius:.25rem; background:#1b1b1b; color:#bbb }
</style>
</head>
<body class="flex flex-col items-center justify-center p-4">
<div class="w-full max-w-7xl mx-auto">
<div class="mb-3 flex items-center justify-between">
<h1 class="text-3xl md:text-4xl font-bold text-yellow-400">SKØYEN STASJON</h1>
<div id="dbg" class="hidden md:flex items-center gap-2 text-xs text-gray-300"></div>
</div>
<div class="mb-4 flex flex-col items-center text-orange-400">
<div class="flex items-center gap-2">
<i data-feather="clock" class="w-5 h-5"></i>
<span id="current-time" class="text-lg font-mono">00:00:00</span>
</div>
<div class="text-xs text-gray-300"><span id="current-date"> </span></div>
</div>
<div class="w-full max-w-7xl mx-auto mt-2 mb-4">
<div class="flex flex-wrap items-end gap-3 text-sm">
<label class="flex flex-col">
<span class="mb-1 text-gray-300">Linjer</span>
<input id="lines-input" class="px-2 py-1 rounded bg-[#1b1b1b] border border-[#444] text-gray-100" placeholder="20,21,31,L1" />
</label>
<label class="flex flex-col">
<span class="mb-1 text-gray-300">Antall per retning</span>
<select id="perline-select" class="px-2 py-1 rounded bg-[#1b1b1b] border border-[#444] text-gray-100">
<option value="1">1</option>
<option value="2" selected>2</option>
<option value="3">3</option>
<option value="4">4</option>
</select>
</label>
<button id="apply-btn" class="px-3 py-1 rounded bg-yellow-500 text-black font-bold">Oppdater</button>
</div>
</div>
<div class="retro-border bg-[#222] p-6 rounded-lg">
<div class="text-center text-yellow-400 font-bold text-lg mb-2">NESTE AVGANGER</div>
<div class="board-grid grid grid-cols-12 gap-2 mb-4 text-center text-yellow-400 font-bold text-sm">
<div class="col-span-1">TYPE</div>
<div class="col-span-2">TID</div>
<div class="col-span-2">LINJE</div>
<div class="col-span-4">DESTINASJON</div>
<div class="col-span-1">PL</div>
<div class="col-span-2">STATUS</div>
</div>
<div id="departures-container"></div>
</div>
</div>
<script type="module">
feather.replace();
class SplitFlap { constructor(el,val){ this.el=el; this.val=val; this.el.innerHTML=`<div>${val}</div>` } setValue(v){ if(v!==this.val){ this.val=v; this.el.innerHTML=`<div>${v}</div>` } } }
const splitFlapDisplays={};
function createSplitFlapDisplay(id,val,len){ const c=document.createElement('div'); c.className='flex'; for(let i=0;i<len;i++){ const ch=val[i]||' '; const d=document.createElement('div'); d.className='split-flap w-6 h-8 md:w-8 md:h-10 mx-0.5 text-lg md:text-xl'; c.appendChild(d); splitFlapDisplays[`${id}_${i}`]=new SplitFlap(d,ch) } return c }
function updateSplitFlapDisplay(id,val,len){ const p=(val||'').padEnd(len,' '); for(let i=0;i<len;i++){ splitFlapDisplays[`${id}_${i}`]?.setValue(p[i]) } }
const SETTINGS = {
clientName: 'echo-skoyen-board/1.3',
wantedLines: ['20','21','31','L1'],
perLineCount: 2,
stopQueryTerms: ['Skøyen', 'Skoyen', 'Skøyen stasjon', 'Skoyen stasjon'],
knownStopIds: ['NSR:StopPlace:59651','NSR:StopPlace:152','NSR:StopPlace:59747','NSR:StopPlace:58287']
};
function parsePlatform(quayName){ if(!quayName) return ''; const m=String(quayName).match(/(\d+|[A-Z])$/i); return m?m[1]:String(quayName).slice(-1); }
function dbgSet(pairs){ const el=document.getElementById('dbg'); if(!el) return; el.innerHTML=pairs.filter(Boolean).map(([k,v])=>`<span class="badge">${k}: ${v}</span>`).join(' '); el.classList.remove('hidden'); }
function normDest(s){ return String(s||'').trim().toUpperCase().replace(/\s+/g,' '); }
async function geocodeStopPlace(term){
const url=`https://api.entur.io/geocoder/v1/autocomplete?text=${encodeURIComponent(term)}&size=10&lang=no`;
const res=await fetch(url,{ headers:{ 'ET-Client-Name': SETTINGS.clientName }});
if(!res.ok) return null;
const js=await res.json();
const features=Array.isArray(js.features)?js.features:[];
const hit=features.find(f=>/StopPlace/.test(f?.properties?.id||'') && /sk[øo]yen/i.test(f?.properties?.name||''));
return hit?hit.properties.id:null;
}
async function resolveStopPlaceId(){
for(const id of SETTINGS.knownStopIds){ if(id){ const ok=await probeStopPlace(id); if(ok) return id; } }
const cached=localStorage.getItem('skoyen_stop_id'); if(cached){ const ok=await probeStopPlace(cached); if(ok) return cached; }
for(const term of SETTINGS.stopQueryTerms){ const id=await geocodeStopPlace(term); if(id){ const ok=await probeStopPlace(id); if(ok){ localStorage.setItem('skoyen_stop_id',id); return id; } } }
throw new Error('Fant ikke StopPlace for Skøyen');
}
async function probeStopPlace(id){
const query=`query($id:String!){ stopPlace(id:$id){ id } }`;
const res=await fetch('https://api.entur.io/journey-planner/v3/graphql',{ method:'POST', headers:{ 'Content-Type':'application/json','ET-Client-Name':SETTINGS.clientName }, body:JSON.stringify({ query, variables:{ id } }) });
if(!res.ok) return false; const js=await res.json(); return Boolean(js?.data?.stopPlace?.id);
}
async function fetchDepartures(stopId){
const query=`query($id:String!){
stopPlace(id:$id){
name
estimatedCalls(numberOfDepartures: 200, timeRange: 21600){
expectedDepartureTime
destinationDisplay{ frontText }
quay{ name publicCode }
serviceJourney{ journeyPattern{ line{ publicCode transportMode transportSubmode } } }
}
}
}`;
const res=await fetch('https://api.entur.io/journey-planner/v3/graphql',{ method:'POST', headers:{ 'Content-Type':'application/json','ET-Client-Name':SETTINGS.clientName }, body:JSON.stringify({ query, variables:{ id: stopId } }) });
if(!res.ok) throw new Error('Entur HTTP '+res.status);
const js=await res.json();
const calls=js?.data?.stopPlace?.estimatedCalls||[];
const placeName=js?.data?.stopPlace?.name||'';
const wantedArr=Array.from(new Set(SETTINGS.wantedLines.map(String)));
const items=calls
.map(c=>({
- README.md +7 -4
- index.html +489 -18
|
@@ -1,10 +1,13 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🚀
|
| 4 |
colorFrom: gray
|
| 5 |
-
colorTo:
|
|
|
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: SplitFlap Express 🚉
|
|
|
|
| 3 |
colorFrom: gray
|
| 4 |
+
colorTo: green
|
| 5 |
+
emoji: 🐳
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite-v3
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Welcome to your new DeepSite project!
|
| 13 |
+
This project was created with [DeepSite](https://deepsite.hf.co).
|
|
@@ -1,19 +1,490 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="no">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Skøyen Stasjon – Split-Flap Avganger</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
|
| 9 |
+
<style>
|
| 10 |
+
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
font-family: 'JetBrains Mono', monospace;
|
| 14 |
+
background: #0a0a0a;
|
| 15 |
+
color: #fff;
|
| 16 |
+
min-height: 100vh;
|
| 17 |
+
overflow-x: hidden;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.split-flap-board {
|
| 21 |
+
background: linear-gradient(135deg, #1a1a1a 0%, #151515 100%);
|
| 22 |
+
border: 1px solid #2a2a2a;
|
| 23 |
+
border-radius: 8px;
|
| 24 |
+
box-shadow:
|
| 25 |
+
0 4px 20px rgba(0, 0, 0, 0.5),
|
| 26 |
+
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
| 27 |
+
position: relative;
|
| 28 |
+
overflow: hidden;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.split-flap-board::before {
|
| 32 |
+
content: '';
|
| 33 |
+
position: absolute;
|
| 34 |
+
top: 0;
|
| 35 |
+
left: 0;
|
| 36 |
+
right: 0;
|
| 37 |
+
bottom: 0;
|
| 38 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='400' viewBox='0 0 400 400'%3E%3Cfilter id='grain'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23grain)' opacity='0.1'/%3E%3C/svg%3E");
|
| 39 |
+
pointer-events: none;
|
| 40 |
+
opacity: 0.15;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.split-flap-cell {
|
| 44 |
+
position: relative;
|
| 45 |
+
background: #1e1e1e;
|
| 46 |
+
border: 1px solid #333;
|
| 47 |
+
border-radius: 2px;
|
| 48 |
+
box-shadow:
|
| 49 |
+
inset 0 1px 2px rgba(0, 0, 0, 0.5),
|
| 50 |
+
0 1px 0 rgba(255, 255, 255, 0.05);
|
| 51 |
+
display: flex;
|
| 52 |
+
align-items: center;
|
| 53 |
+
justify-content: center;
|
| 54 |
+
overflow: hidden;
|
| 55 |
+
perspective: 200px;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.split-flap-character {
|
| 59 |
+
position: absolute;
|
| 60 |
+
width: 100%;
|
| 61 |
+
height: 100%;
|
| 62 |
+
display: flex;
|
| 63 |
+
align-items: center;
|
| 64 |
+
justify-content: center;
|
| 65 |
+
color: #ffcc00;
|
| 66 |
+
font-weight: 700;
|
| 67 |
+
text-shadow: 0 0 2px rgba(255, 204, 0, 0.5);
|
| 68 |
+
backface-visibility: hidden;
|
| 69 |
+
transform-style: preserve-3d;
|
| 70 |
+
transition: none;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.split-flap-character.flipping {
|
| 74 |
+
animation: flip 0.15s ease-out forwards;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@keyframes flip {
|
| 78 |
+
0% {
|
| 79 |
+
transform: rotateX(0deg);
|
| 80 |
+
}
|
| 81 |
+
50% {
|
| 82 |
+
transform: rotateX(-90deg);
|
| 83 |
+
}
|
| 84 |
+
100% {
|
| 85 |
+
transform: rotateX(0deg);
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.split-flap-character.numeric {
|
| 90 |
+
animation-duration: 0.12s;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.header-cell {
|
| 94 |
+
color: #ffcc00;
|
| 95 |
+
font-weight: 700;
|
| 96 |
+
text-transform: uppercase;
|
| 97 |
+
letter-spacing: 0.5px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.status-on-time {
|
| 101 |
+
color: #aaffaa !important;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.flap-icon {
|
| 105 |
+
filter: brightness(1.2) contrast(1.2);
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.board-grid {
|
| 109 |
+
display: grid;
|
| 110 |
+
grid-template-columns: 0.8fr 1.2fr 1.2fr 2.8fr 0.8fr 1.2fr;
|
| 111 |
+
gap: 4px;
|
| 112 |
+
padding: 8px;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.no-departures {
|
| 116 |
+
grid-column: 1 / -1;
|
| 117 |
+
display: flex;
|
| 118 |
+
justify-content: center;
|
| 119 |
+
align-items: center;
|
| 120 |
+
height: 48px;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
@media (max-width: 768px) {
|
| 124 |
+
.board-grid {
|
| 125 |
+
grid-template-columns: 0.8fr 1.2fr 1.2fr 2fr 0.8fr 1.2fr;
|
| 126 |
+
gap: 2px;
|
| 127 |
+
padding: 6px;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
.split-flap-cell {
|
| 131 |
+
height: 32px;
|
| 132 |
+
font-size: 14px;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
@media (max-width: 640px) {
|
| 137 |
+
.board-grid {
|
| 138 |
+
grid-template-columns: 0.8fr 1.2fr 1fr 1.6fr 0.8fr 1fr;
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
</style>
|
| 142 |
+
</head>
|
| 143 |
+
<body class="flex flex-col items-center justify-center p-4 md:p-6 bg-gray-900">
|
| 144 |
+
<div class="w-full max-w-6xl mx-auto">
|
| 145 |
+
<div class="mb-4 flex items-center justify-between">
|
| 146 |
+
<h1 class="text-2xl md:text-3xl font-bold text-yellow-400 tracking-wide">SKØYEN STASJON</h1>
|
| 147 |
+
<div id="dbg" class="hidden md:flex items-center gap-2 text-xs text-gray-400"></div>
|
| 148 |
+
</div>
|
| 149 |
+
|
| 150 |
+
<div class="mb-6 flex flex-col items-center text-orange-300">
|
| 151 |
+
<div class="flex items-center gap-2">
|
| 152 |
+
<i data-feather="clock" class="w-4 h-4"></i>
|
| 153 |
+
<span id="current-time" class="text-base md:text-lg font-mono">00:00:00</span>
|
| 154 |
+
</div>
|
| 155 |
+
<div class="text-xs text-gray-400 mt-1"><span id="current-date"> </span></div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
<div class="w-full max-w-6xl mx-auto mb-6">
|
| 159 |
+
<div class="flex flex-wrap items-end gap-3 text-sm">
|
| 160 |
+
<label class="flex flex-col">
|
| 161 |
+
<span class="mb-1 text-gray-300">Linjer</span>
|
| 162 |
+
<input id="lines-input" class="px-3 py-2 rounded bg-gray-800 border border-gray-600 text-gray-100 focus:border-yellow-500 focus:outline-none transition-colors" placeholder="20,21,31,L1" />
|
| 163 |
+
</label>
|
| 164 |
+
<label class="flex flex-col">
|
| 165 |
+
<span class="mb-1 text-gray-300">Antall per retning</span>
|
| 166 |
+
<select id="perline-select" class="px-3 py-2 rounded bg-gray-800 border border-gray-600 text-gray-100 focus:border-yellow-500 focus:outline-none transition-colors">
|
| 167 |
+
<option value="1">1</option>
|
| 168 |
+
<option value="2" selected>2</option>
|
| 169 |
+
<option value="3">3</option>
|
| 170 |
+
<option value="4">4</option>
|
| 171 |
+
</select>
|
| 172 |
+
</label>
|
| 173 |
+
<button id="apply-btn" class="px-4 py-2 rounded bg-yellow-500 text-black font-bold hover:bg-yellow-400 active:bg-yellow-600 transition-colors">Oppdater</button>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div class="split-flap-board">
|
| 178 |
+
<div class="board-grid mb-3">
|
| 179 |
+
<div class="header-cell">TYPE</div>
|
| 180 |
+
<div class="header-cell">TID</div>
|
| 181 |
+
<div class="header-cell">LINJE</div>
|
| 182 |
+
<div class="header-cell">DESTINASJON</div>
|
| 183 |
+
<div class="header-cell">PL</div>
|
| 184 |
+
<div class="header-cell">STATUS</div>
|
| 185 |
+
</div>
|
| 186 |
+
<div id="departures-container"></div>
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<script type="module">
|
| 191 |
+
feather.replace();
|
| 192 |
+
|
| 193 |
+
class SplitFlap {
|
| 194 |
+
constructor(el, initialVal) {
|
| 195 |
+
this.el = el;
|
| 196 |
+
this.currentVal = initialVal || ' ';
|
| 197 |
+
this.isNumeric = /^\d$/.test(this.currentVal);
|
| 198 |
+
this.createCharacterElement();
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
createCharacterElement() {
|
| 202 |
+
this.el.innerHTML = '';
|
| 203 |
+
const charEl = document.createElement('div');
|
| 204 |
+
charEl.className = `split-flap-character ${this.isNumeric ? 'numeric' : ''}`;
|
| 205 |
+
charEl.textContent = this.currentVal;
|
| 206 |
+
this.el.appendChild(charEl);
|
| 207 |
+
this.charEl = charEl;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
setValue(newVal, delay = 0) {
|
| 211 |
+
if (newVal === this.currentVal) return;
|
| 212 |
+
|
| 213 |
+
this.isNumeric = /^\d$/.test(newVal);
|
| 214 |
+
setTimeout(() => {
|
| 215 |
+
this.charEl.classList.add('flipping');
|
| 216 |
+
setTimeout(() => {
|
| 217 |
+
this.currentVal = newVal;
|
| 218 |
+
this.charEl.textContent = newVal;
|
| 219 |
+
this.charEl.className = `split-flap-character ${this.isNumeric ? 'numeric' : ''}`;
|
| 220 |
+
setTimeout(() => this.charEl.classList.remove('flipping'), 10);
|
| 221 |
+
}, this.isNumeric ? 60 : 75);
|
| 222 |
+
}, delay);
|
| 223 |
+
}
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
const splitFlapDisplays = {};
|
| 227 |
+
|
| 228 |
+
function createSplitFlapDisplay(id, val, len, colType = 'text') {
|
| 229 |
+
const container = document.createElement('div');
|
| 230 |
+
container.className = 'flex justify-center';
|
| 231 |
+
|
| 232 |
+
for (let i = 0; i < len; i++) {
|
| 233 |
+
const ch = (val && val[i]) || ' ';
|
| 234 |
+
const cell = document.createElement('div');
|
| 235 |
+
const size = colType === 'icon' ? 'w-6 h-8' : 'w-6 h-8';
|
| 236 |
+
cell.className = `split-flap-cell ${size} mx-0.5`;
|
| 237 |
+
|
| 238 |
+
container.appendChild(cell);
|
| 239 |
+
splitFlapDisplays[`${id}_${i}`] = new SplitFlap(cell, ch);
|
| 240 |
+
}
|
| 241 |
+
return container;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function updateSplitFlapDisplay(id, val, len, colType = 'text') {
|
| 245 |
+
const padded = (val || '').padEnd(len, ' ');
|
| 246 |
+
const isNumericCol = colType === 'numeric';
|
| 247 |
+
|
| 248 |
+
for (let i = 0; i < len; i++) {
|
| 249 |
+
const delay = i * (isNumericCol ? 30 : 40);
|
| 250 |
+
splitFlapDisplays[`${id}_${i}`]?.setValue(padded[i], delay);
|
| 251 |
+
}
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const SETTINGS = {
|
| 255 |
+
clientName: 'echo-skoyen-board/1.3',
|
| 256 |
+
wantedLines: ['20','21','31','L1'],
|
| 257 |
+
perLineCount: 2,
|
| 258 |
+
stopQueryTerms: ['Skøyen', 'Skoyen', 'Skøyen stasjon', 'Skoyen stasjon'],
|
| 259 |
+
knownStopIds: ['NSR:StopPlace:59651','NSR:StopPlace:152','NSR:StopPlace:59747','NSR:StopPlace:58287']
|
| 260 |
+
};
|
| 261 |
+
|
| 262 |
+
function parsePlatform(quayName){ if(!quayName) return ''; const m=String(quayName).match(/(\d+|[A-Z])$/i); return m?m[1]:String(quayName).slice(-1); }
|
| 263 |
+
function dbgSet(pairs){ const el=document.getElementById('dbg'); if(!el) return; el.innerHTML=pairs.filter(Boolean).map(([k,v])=>`<span class="px-2 py-1 bg-gray-800 rounded text-xs border border-gray-700">${k}: ${v}</span>`).join(' '); el.classList.remove('hidden'); }
|
| 264 |
+
function normDest(s){ return String(s||'').trim().toUpperCase().replace(/\s+/g,' '); }
|
| 265 |
+
|
| 266 |
+
async function geocodeStopPlace(term){
|
| 267 |
+
const url=`https://api.entur.io/geocoder/v1/autocomplete?text=${encodeURIComponent(term)}&size=10&lang=no`;
|
| 268 |
+
const res=await fetch(url,{ headers:{ 'ET-Client-Name': SETTINGS.clientName }});
|
| 269 |
+
if(!res.ok) return null;
|
| 270 |
+
const js=await res.json();
|
| 271 |
+
const features=Array.isArray(js.features)?js.features:[];
|
| 272 |
+
const hit=features.find(f=>/StopPlace/.test(f?.properties?.id||'') && /sk[øo]yen/i.test(f?.properties?.name||''));
|
| 273 |
+
return hit?hit.properties.id:null;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
async function resolveStopPlaceId(){
|
| 277 |
+
for(const id of SETTINGS.knownStopIds){ if(id){ const ok=await probeStopPlace(id); if(ok) return id; } }
|
| 278 |
+
const cached=localStorage.getItem('skoyen_stop_id'); if(cached){ const ok=await probeStopPlace(cached); if(ok) return cached; }
|
| 279 |
+
for(const term of SETTINGS.stopQueryTerms){ const id=await geocodeStopPlace(term); if(id){ const ok=await probeStopPlace(id); if(ok){ localStorage.setItem('skoyen_stop_id',id); return id; } } }
|
| 280 |
+
throw new Error('Fant ikke StopPlace for Skøyen');
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
async function probeStopPlace(id){
|
| 284 |
+
const query=`query($id:String!){ stopPlace(id:$id){ id } }`;
|
| 285 |
+
const res=await fetch('https://api.entur.io/journey-planner/v3/graphql',{ method:'POST', headers:{ 'Content-Type':'application/json','ET-Client-Name':SETTINGS.clientName }, body:JSON.stringify({ query, variables:{ id } }) });
|
| 286 |
+
if(!res.ok) return false; const js=await res.json(); return Boolean(js?.data?.stopPlace?.id);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function fetchDepartures(stopId){
|
| 290 |
+
const query=`query($id:String!){
|
| 291 |
+
stopPlace(id:$id){
|
| 292 |
+
name
|
| 293 |
+
estimatedCalls(numberOfDepartures: 200, timeRange: 21600){
|
| 294 |
+
expectedDepartureTime
|
| 295 |
+
destinationDisplay{ frontText }
|
| 296 |
+
quay{ name publicCode }
|
| 297 |
+
serviceJourney{ journeyPattern{ line{ publicCode transportMode transportSubmode } } }
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
}`;
|
| 301 |
+
const res=await fetch('https://api.entur.io/journey-planner/v3/graphql',{ method:'POST', headers:{ 'Content-Type':'application/json','ET-Client-Name':SETTINGS.clientName }, body:JSON.stringify({ query, variables:{ id: stopId } }) });
|
| 302 |
+
if(!res.ok) throw new Error('Entur HTTP '+res.status);
|
| 303 |
+
const js=await res.json();
|
| 304 |
+
const calls=js?.data?.stopPlace?.estimatedCalls||[];
|
| 305 |
+
const placeName=js?.data?.stopPlace?.name||'';
|
| 306 |
+
|
| 307 |
+
const wantedArr=Array.from(new Set(SETTINGS.wantedLines.map(String)));
|
| 308 |
+
const items=calls
|
| 309 |
+
.map(c=>({
|
| 310 |
+
iso:c?.expectedDepartureTime,
|
| 311 |
+
line:c?.serviceJourney?.journeyPattern?.line?.publicCode||'',
|
| 312 |
+
mode:(c?.serviceJourney?.journeyPattern?.line?.transportMode||'').toLowerCase(),
|
| 313 |
+
submode:(c?.serviceJourney?.journeyPattern?.line?.transportSubmode||'').toLowerCase(),
|
| 314 |
+
destination:normDest(c?.destinationDisplay?.frontText||''),
|
| 315 |
+
platform:(c?.quay?.publicCode||parsePlatform(c?.quay?.name||'')),
|
| 316 |
+
status:'PÅ TID'
|
| 317 |
+
}))
|
| 318 |
+
.filter(x=>wantedArr.some(w=>String(x.line).startsWith(w)))
|
| 319 |
+
.sort((a,b)=>new Date(a.iso)-new Date(b.iso));
|
| 320 |
+
|
| 321 |
+
const perKey=new Map();
|
| 322 |
+
for(const it of items){
|
| 323 |
+
const base=wantedArr.find(w=>String(it.line).startsWith(w))||it.line;
|
| 324 |
+
const dirKey=it.destination;
|
| 325 |
+
const key=`${base}__${dirKey}`;
|
| 326 |
+
if(!perKey.has(key)) perKey.set(key,[]);
|
| 327 |
+
const arr=perKey.get(key);
|
| 328 |
+
if(arr.length < Number(SETTINGS.perLineCount)) arr.push(it);
|
| 329 |
+
}
|
| 330 |
+
const kept=Array.from(perKey.values()).flat();
|
| 331 |
+
|
| 332 |
+
const rows=kept
|
| 333 |
+
.sort((a,b)=>new Date(a.iso)-new Date(b.iso))
|
| 334 |
+
.map(x=>({
|
| 335 |
+
time:new Date(x.iso).toLocaleTimeString('no-NO',{ hour:'2-digit', minute:'2-digit' }),
|
| 336 |
+
line:x.line,
|
| 337 |
+
destination:x.destination,
|
| 338 |
+
platform:x.platform||'',
|
| 339 |
+
status:x.status,
|
| 340 |
+
mode:x.mode,
|
| 341 |
+
submode:x.submode
|
| 342 |
+
}));
|
| 343 |
+
|
| 344 |
+
dbgSet([["StopPlace", stopId],["Navn", placeName],["Linjer", wantedArr.join(',')],["Per retning", String(SETTINGS.perLineCount)],["Viser", String(rows.length)]]);
|
| 345 |
+
return rows;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function getModeIcon(mode, submode){
|
| 349 |
+
switch(mode){
|
| 350 |
+
case 'bus':
|
| 351 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M3 11h18"/><circle cx="7" cy="19" r="1"/><circle cx="17" cy="19" r="1"/></svg>';
|
| 352 |
+
case 'rail':
|
| 353 |
+
case 'train':
|
| 354 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="12" rx="2"/><path d="M8 15l-3 6"/><path d="M16 15l3 6"/><path d="M5 10h14"/></svg>';
|
| 355 |
+
case 'metro':
|
| 356 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="12" rx="2"/><path d="M8 21l4-3 4 3"/></svg>';
|
| 357 |
+
case 'tram':
|
| 358 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="4" width="16" height="10" rx="2"/><path d="M4 10h16"/><path d="M8 20l-3-6"/><path d="M16 20l3-6"/></svg>';
|
| 359 |
+
case 'water':
|
| 360 |
+
case 'ferry':
|
| 361 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 18c3 2 6 2 9 0s6-2 9 0"/><path d="M4 14h16l-2-6H6l-2 6z"/></svg>';
|
| 362 |
+
default:
|
| 363 |
+
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 flap-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/></svg>';
|
| 364 |
+
}
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
function updateBoard(type, data) {
|
| 368 |
+
const cont = document.getElementById(`${type}-container`) || document.getElementById('departures-container');
|
| 369 |
+
cont.innerHTML = '';
|
| 370 |
+
|
| 371 |
+
if (!data || !data.length) {
|
| 372 |
+
const noDepartures = document.createElement('div');
|
| 373 |
+
noDepartures.className = 'no-departures';
|
| 374 |
+
noDepartures.appendChild(createSplitFlapDisplay('no_departures', 'NO DEPARTURES', 12));
|
| 375 |
+
cont.appendChild(noDepartures);
|
| 376 |
+
setTimeout(() => updateSplitFlapDisplay('no_departures', 'NO DEPARTURES', 12), 100);
|
| 377 |
+
return;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
data.forEach((item, i) => {
|
| 381 |
+
const row = document.createElement('div');
|
| 382 |
+
row.className = 'board-grid items-center';
|
| 383 |
+
|
| 384 |
+
// Icon column
|
| 385 |
+
const iconCol = document.createElement('div');
|
| 386 |
+
iconCol.className = 'flex justify-center items-center';
|
| 387 |
+
const iconContainer = document.createElement('div');
|
| 388 |
+
iconContainer.className = 'split-flap-cell w-6 h-8 mx-0.5 flex items-center justify-center';
|
| 389 |
+
iconContainer.innerHTML = getModeIcon(item.mode, item.submode);
|
| 390 |
+
iconCol.appendChild(iconContainer);
|
| 391 |
+
row.appendChild(iconCol);
|
| 392 |
+
|
| 393 |
+
// Time column
|
| 394 |
+
const timeCol = document.createElement('div');
|
| 395 |
+
timeCol.appendChild(createSplitFlapDisplay(`${type}_t_${i}`, item.time, 5, 'numeric'));
|
| 396 |
+
row.appendChild(timeCol);
|
| 397 |
+
|
| 398 |
+
// Line column
|
| 399 |
+
const lineCol = document.createElement('div');
|
| 400 |
+
lineCol.appendChild(createSplitFlapDisplay(`${type}_l_${i}`, item.line, 4, 'numeric'));
|
| 401 |
+
row.appendChild(lineCol);
|
| 402 |
+
|
| 403 |
+
// Destination column
|
| 404 |
+
const destCol = document.createElement('div');
|
| 405 |
+
destCol.appendChild(createSplitFlapDisplay(`${type}_d_${i}`, item.destination, 16));
|
| 406 |
+
row.appendChild(destCol);
|
| 407 |
+
|
| 408 |
+
// Platform column
|
| 409 |
+
const platformCol = document.createElement('div');
|
| 410 |
+
platformCol.appendChild(createSplitFlapDisplay(`${type}_p_${i}`, item.platform, 2, 'numeric'));
|
| 411 |
+
row.appendChild(platformCol);
|
| 412 |
+
|
| 413 |
+
// Status column
|
| 414 |
+
const statusCol = document.createElement('div');
|
| 415 |
+
const statusDisplay = createSplitFlapDisplay(`${type}_s_${i}`, item.status, 6);
|
| 416 |
+
if (item.status === 'PÅ TID') {
|
| 417 |
+
statusDisplay.classList.add('status-on-time');
|
| 418 |
+
}
|
| 419 |
+
statusCol.appendChild(statusDisplay);
|
| 420 |
+
row.appendChild(statusCol);
|
| 421 |
+
|
| 422 |
+
cont.appendChild(row);
|
| 423 |
+
|
| 424 |
+
// Update with staggered animation
|
| 425 |
+
setTimeout(() => {
|
| 426 |
+
updateSplitFlapDisplay(`${type}_t_${i}`, item.time, 5, 'numeric');
|
| 427 |
+
updateSplitFlapDisplay(`${type}_l_${i}`, item.line, 4, 'numeric');
|
| 428 |
+
updateSplitFlapDisplay(`${type}_d_${i}`, item.destination, 16);
|
| 429 |
+
updateSplitFlapDisplay(`${type}_p_${i}`, item.platform, 2, 'numeric');
|
| 430 |
+
updateSplitFlapDisplay(`${type}_s_${i}`, item.status, 6);
|
| 431 |
+
}, i * 200);
|
| 432 |
+
});
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function updateClock() {
|
| 436 |
+
const now = new Date();
|
| 437 |
+
const t = document.getElementById('current-time');
|
| 438 |
+
const d = document.getElementById('current-date');
|
| 439 |
+
if (t) t.textContent = now.toLocaleTimeString('no-NO');
|
| 440 |
+
if (d) d.textContent = now.toLocaleDateString('no-NO', {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'});
|
| 441 |
+
}
|
| 442 |
+
setInterval(updateClock, 1000);
|
| 443 |
+
updateClock();
|
| 444 |
+
|
| 445 |
+
(async function main() {
|
| 446 |
+
const li = document.getElementById('lines-input');
|
| 447 |
+
if (li) li.value = SETTINGS.wantedLines.join(',');
|
| 448 |
+
const ps = document.getElementById('perline-select');
|
| 449 |
+
if (ps) ps.value = String(SETTINGS.perLineCount);
|
| 450 |
+
|
| 451 |
+
let stopId;
|
| 452 |
+
try {
|
| 453 |
+
stopId = await resolveStopPlaceId();
|
| 454 |
+
const rows = await fetchDepartures(stopId);
|
| 455 |
+
updateBoard('departures', rows);
|
| 456 |
+
} catch (e) {
|
| 457 |
+
console.error(e);
|
| 458 |
+
dbgSet([["Error", String(e.message || e)]]);
|
| 459 |
+
updateBoard('departures', []);
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
const apply = document.getElementById('apply-btn');
|
| 463 |
+
if (apply) {
|
| 464 |
+
apply.addEventListener('click', async () => {
|
| 465 |
+
const raw = (document.getElementById('lines-input') || {}).value;
|
| 466 |
+
const per = Number((document.getElementById('perline-select') || {}).value) || 2;
|
| 467 |
+
const arr = Array.from(new Set(String(raw || '').split(/[ ,;]+/).map(s => s.trim()).filter(Boolean)));
|
| 468 |
+
SETTINGS.wantedLines = arr.length ? arr : [];
|
| 469 |
+
SETTINGS.perLineCount = per;
|
| 470 |
+
if (stopId) {
|
| 471 |
+
const rows = await fetchDepartures(stopId);
|
| 472 |
+
updateBoard('departures', rows);
|
| 473 |
+
}
|
| 474 |
+
});
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
setInterval(async () => {
|
| 478 |
+
try {
|
| 479 |
+
if (stopId) {
|
| 480 |
+
const rows = await fetchDepartures(stopId);
|
| 481 |
+
updateBoard('departures', rows);
|
| 482 |
+
}
|
| 483 |
+
} catch (e) {
|
| 484 |
+
console.error(e);
|
| 485 |
+
}
|
| 486 |
+
}, 30000);
|
| 487 |
+
})();
|
| 488 |
+
</script>
|
| 489 |
+
</body>
|
| 490 |
</html>
|