BruceBanners commited on
Commit
94a1c7e
·
verified ·
1 Parent(s): 7767bb2

Make my display more like a slat flip board with the correcct animation. Think of a retro style split flap board in a train station or such,

Browse files

<!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=>({
iso:c?.expectedDepartureTime,
line:c?.serviceJourney?.journeyPattern?.line?.publicCode||'',
mode:(c?.serviceJourney?.journeyPattern?.line?.transportMode||'').toLowerCase(),
submode:(c?.serviceJourney?.journeyPattern?.line?.transportSubmode||'').toLowerCase(),
destination:normDest(c?.destinationDisplay?.frontText||''),
platform:(c?.quay?.publicCode||parsePlatform(c?.quay?.name||'')),
status:'PÅ TID'
}))
.filter(x=>wantedArr.some(w=>String(x.line).startsWith(w)))
.sort((a,b)=>new Date(a.iso)-new Date(b.iso));

const perKey=new Map();
for(const it of items){
const base=wantedArr.find(w=>String(it.line).startsWith(w))||it.line;
const dirKey=it.destination;
const key=`${base}__${dirKey}`;
if(!perKey.has(key)) perKey.set(key,[]);
const arr=perKey.get(key);
if(arr.length < Number(SETTINGS.perLineCount)) arr.push(it);
}
const kept=Array.from(perKey.values()).flat();

const rows=kept
.sort((a,b)=>new Date(a.iso)-new Date(b.iso))
.map(x=>({
time:new Date(x.iso).toLocaleTimeString('no-NO',{ hour:'2-digit', minute:'2-digit' }),
line:x.line,
destination:x.destination,
platform:x.platform||'',
status:x.status,
mode:x.mode,
submode:x.submode
}));

dbgSet([["StopPlace", stopId],["Navn", placeName],["Linjer", wantedArr.join(',')],["Per retning", String(SETTINGS.perLineCount)],["Viser", String(rows.length)]]);
return rows;
}

function getModeIcon(mode, submode){
switch(mode){
case 'bus':
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" 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>';
case 'rail':
case 'train':
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" 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>';
case 'metro':
return '<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" 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>';
case 'tram':
return '<svg xmlns="http://www.

Files changed (2) hide show
  1. README.md +7 -4
  2. index.html +546 -18
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Sk Yen Split Flapper 3000
3
- emoji: 📈
4
  colorFrom: red
5
- colorTo: blue
 
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
1
  ---
2
+ title: Skøyen Split-Flapper 3000 🚂
 
3
  colorFrom: red
4
+ colorTo: yellow
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).
index.html CHANGED
@@ -1,19 +1,547 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-Flapp 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
+ <script src="https://unpkg.com/feather-icons"></script>
10
+ <style>
11
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
12
+
13
+ body {
14
+ font-family: 'JetBrains Mono', monospace;
15
+ background: #0a0a0a;
16
+ color: #ffcc00;
17
+ min-height: 100vh;
18
+ overflow-x: hidden;
19
+ background-image: radial-gradient(circle at 1% 1%, #222 0%, #0a0a0a 20%);
20
+ }
21
+
22
+ .retro-border {
23
+ border: 6px solid #333;
24
+ border-radius: 12px;
25
+ box-shadow:
26
+ inset 0 0 20px rgba(0,0,0,0.8),
27
+ 0 0 30px rgba(0,0,0,0.6),
28
+ 0 0 0 2px #555;
29
+ background: linear-gradient(145deg, #1a1a1a, #222);
30
+ }
31
+
32
+ .split-flap {
33
+ background: #111;
34
+ color: #ffcc00;
35
+ border: 2px solid #333;
36
+ border-radius: 4px;
37
+ display: inline-flex;
38
+ align-items: center;
39
+ justify-content: center;
40
+ font-weight: bold;
41
+ box-shadow:
42
+ inset 0 -2px 4px rgba(0,0,0,0.5),
43
+ inset 0 2px 4px rgba(255,255,255,0.1),
44
+ 0 2px 4px rgba(0,0,0,0.8);
45
+ overflow: hidden;
46
+ position: relative;
47
+ perspective: 200px;
48
+ }
49
+
50
+ .split-flap > div {
51
+ position: absolute;
52
+ width: 100%;
53
+ height: 100%;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ backface-visibility: hidden;
58
+ transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
59
+ }
60
+
61
+ .split-flap.flipping > div:first-child {
62
+ transform: rotateX(-90deg);
63
+ }
64
+
65
+ .split-flap.flipping > div:last-child {
66
+ transform: rotateX(0deg);
67
+ }
68
+
69
+ .split-flap > div:last-child {
70
+ transform: rotateX(90deg);
71
+ }
72
+
73
+ .mechanical-sound {
74
+ position: fixed;
75
+ top: -100px;
76
+ left: -100px;
77
+ opacity: 0;
78
+ pointer-events: none;
79
+ }
80
+
81
+ .glow-text {
82
+ text-shadow: 0 0 10px #ffcc00, 0 0 20px #ffcc00, 0 0 30px #ff9900;
83
+ }
84
+
85
+ .flicker {
86
+ animation: flicker 2s infinite alternate;
87
+ }
88
+
89
+ @keyframes flicker {
90
+ 0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% {
91
+ opacity: 1;
92
+ }
93
+ 20%, 24%, 55% {
94
+ opacity: 0.6;
95
+ }
96
+ }
97
+
98
+ .scan-line {
99
+ position: fixed;
100
+ top: 0;
101
+ left: 0;
102
+ right: 0;
103
+ height: 2px;
104
+ background: linear-gradient(to bottom, rgba(255,204,0,0.3), transparent);
105
+ animation: scan 3s linear infinite;
106
+ pointer-events: none;
107
+ z-index: 9999;
108
+ }
109
+
110
+ @keyframes scan {
111
+ 0% { top: 0; }
112
+ 100% { top: 100vh; }
113
+ }
114
+ </style>
115
+ </head>
116
+ <body class="flex flex-col items-center justify-center p-4 pt-8">
117
+ <div class="scan-line"></div>
118
+ <audio id="flapSound" class="mechanical-sound" preload="auto">
119
+ <source src="https://assets.mixkit.co/sfx/preview/mixkit-mechanical-clock-pulse-1001.mp3" type="audio/mpeg">
120
+ </audio>
121
+
122
+ <div class="w-full max-w-6xl mx-auto">
123
+ <div class="mb-6 flex items-center justify-between">
124
+ <h1 class="text-4xl md:text-5xl font-bold glow-text flicker">SKØYEN STASJON</h1>
125
+ <div id="dbg" class="hidden md:flex items-center gap-2 text-xs text-gray-400"></div>
126
+ </div>
127
+
128
+ <div class="mb-6 flex flex-col items-center text-orange-300">
129
+ <div class="flex items-center gap-3">
130
+ <i data-feather="clock" class="w-6 h-6 text-yellow-400"></i>
131
+ <span id="current-time" class="text-xl md:text-2xl font-mono glow-text">00:00:00</span>
132
+ </div>
133
+ <div class="text-sm text-gray-400 mt-1"><span id="current-date">&nbsp;</span></div>
134
+ </div>
135
+
136
+ <div class="w-full max-w-6xl mx-auto mt-4 mb-6">
137
+ <div class="flex flex-wrap items-end gap-4 text-sm bg-[#1a1a1a] p-3 rounded-lg border border-[#333]">
138
+ <label class="flex flex-col">
139
+ <span class="mb-1 text-gray-300">Linjer</span>
140
+ <input id="lines-input" class="px-3 py-2 rounded bg-[#111] border border-[#444] text-gray-100 focus:border-yellow-500 focus:outline-none" placeholder="20,21,31,L1" />
141
+ </label>
142
+ <label class="flex flex-col">
143
+ <span class="mb-1 text-gray-300">Antall per retning</span>
144
+ <select id="perline-select" class="px-3 py-2 rounded bg-[#111] border border-[#444] text-gray-100 focus:border-yellow-500 focus:outline-none">
145
+ <option value="1">1</option>
146
+ <option value="2" selected>2</option>
147
+ <option value="3">3</option>
148
+ <option value="4">4</option>
149
+ </select>
150
+ </label>
151
+ <button id="apply-btn" class="px-4 py-2 rounded bg-yellow-600 hover:bg-yellow-500 text-black font-bold transition-colors duration-200">
152
+ OPPDATER
153
+ </button>
154
+ </div>
155
+ </div>
156
+
157
+ <div class="retro-border p-6 md:p-8">
158
+ <div class="text-center text-yellow-400 font-bold text-xl mb-4 glow-text">NESTE AVGANGER</div>
159
+ <div class="grid grid-cols-12 gap-3 mb-5 text-center text-yellow-400 font-bold text-sm border-b border-[#444] pb-2">
160
+ <div class="col-span-1">TYPE</div>
161
+ <div class="col-span-2">TID</div>
162
+ <div class="col-span-2">LINJE</div>
163
+ <div class="col-span-4">DESTINASJON</div>
164
+ <div class="col-span-1">PL</div>
165
+ <div class="col-span-2">STATUS</div>
166
+ </div>
167
+ <div id="departures-container" class="min-h-[300px]"></div>
168
+ </div>
169
+ </div>
170
+
171
+ <script type="module">
172
+ feather.replace();
173
+
174
+ class SplitFlap {
175
+ constructor(el, initialVal) {
176
+ this.el = el;
177
+ this.val = initialVal;
178
+ this.el.innerHTML = `<div>${initialVal}</div><div>${initialVal}</div>`;
179
+ this.el.classList.add('split-flap');
180
+ }
181
+
182
+ setValue(newVal) {
183
+ if (newVal !== this.val) {
184
+ const oldVal = this.val;
185
+ this.val = newVal;
186
+
187
+ // Update the back face with new value
188
+ this.el.lastElementChild.textContent = newVal;
189
+
190
+ // Trigger flip animation
191
+ this.el.classList.add('flipping');
192
+
193
+ // Play sound effect
194
+ const sound = document.getElementById('flapSound');
195
+ if (sound) {
196
+ sound.currentTime = 0;
197
+ sound.play().catch(() => {});
198
+ }
199
+
200
+ // Reset animation after completion
201
+ setTimeout(() => {
202
+ this.el.classList.remove('flipping');
203
+ this.el.firstElementChild.textContent = newVal;
204
+ }, 300);
205
+ }
206
+ }
207
+ }
208
+
209
+ const splitFlapDisplays = {};
210
+
211
+ function createSplitFlapDisplay(id, val, len) {
212
+ const container = document.createElement('div');
213
+ container.className = 'flex';
214
+
215
+ for (let i = 0; i < len; i++) {
216
+ const char = val[i] || ' ';
217
+ const flap = document.createElement('div');
218
+ flap.className = 'split-flap w-7 h-10 md:w-8 md:h-12 mx-0.5 text-xl md:text-2xl';
219
+ container.appendChild(flap);
220
+ splitFlapDisplays[`${id}_${i}`] = new SplitFlap(flap, char);
221
+ }
222
+
223
+ return container;
224
+ }
225
+
226
+ function updateSplitFlapDisplay(id, val, len) {
227
+ const padded = (val || '').padEnd(len, ' ');
228
+ for (let i = 0; i < len; i++) {
229
+ if (splitFlapDisplays[`${id}_${i}`]) {
230
+ setTimeout(() => {
231
+ splitFlapDisplays[`${id}_${i}`].setValue(padded[i]);
232
+ }, 100 * i);
233
+ }
234
+ }
235
+ }
236
+
237
+ const SETTINGS = {
238
+ clientName: 'echo-skoyen-board/1.3',
239
+ wantedLines: ['20','21','31','L1'],
240
+ perLineCount: 2,
241
+ stopQueryTerms: ['Skøyen', 'Skoyen', 'Skøyen stasjon', 'Skoyen stasjon'],
242
+ knownStopIds: ['NSR:StopPlace:59651','NSR:StopPlace:152','NSR:StopPlace:59747','NSR:StopPlace:58287']
243
+ };
244
+
245
+ function parsePlatform(quayName) {
246
+ if (!quayName) return '';
247
+ const m = String(quayName).match(/(\d+|[A-Z])$/i);
248
+ return m ? m[1] : String(quayName).slice(-1);
249
+ }
250
+
251
+ function dbgSet(pairs) {
252
+ const el = document.getElementById('dbg');
253
+ if (!el) return;
254
+ el.innerHTML = pairs.filter(Boolean).map(([k, v]) => `<span class="badge bg-[#222] border-[#444] text-gray-300 px-2 py-1 rounded">${k}: ${v}</span>`).join(' ');
255
+ el.classList.remove('hidden');
256
+ }
257
+
258
+ function normDest(s) {
259
+ return String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
260
+ }
261
+
262
+ async function geocodeStopPlace(term) {
263
+ const url = `https://api.entur.io/geocoder/v1/autocomplete?text=${encodeURIComponent(term)}&size=10&lang=no`;
264
+ const res = await fetch(url, { headers: { 'ET-Client-Name': SETTINGS.clientName } });
265
+ if (!res.ok) return null;
266
+ const js = await res.json();
267
+ const features = Array.isArray(js.features) ? js.features : [];
268
+ const hit = features.find(f => /StopPlace/.test(f?.properties?.id || '') && /sk[øo]yen/i.test(f?.properties?.name || ''));
269
+ return hit ? hit.properties.id : null;
270
+ }
271
+
272
+ async function probeStopPlace(id) {
273
+ const query = `query($id:String!){ stopPlace(id:$id){ id } }`;
274
+ const res = await fetch('https://api.entur.io/journey-planner/v3/graphql', {
275
+ method: 'POST',
276
+ headers: { 'Content-Type': 'application/json', 'ET-Client-Name': SETTINGS.clientName },
277
+ body: JSON.stringify({ query, variables: { id } })
278
+ });
279
+ if (!res.ok) return false;
280
+ const js = await res.json();
281
+ return Boolean(js?.data?.stopPlace?.id);
282
+ }
283
+
284
+ async function resolveStopPlaceId() {
285
+ for (const id of SETTINGS.knownStopIds) {
286
+ if (id) {
287
+ const ok = await probeStopPlace(id);
288
+ if (ok) return id;
289
+ }
290
+ }
291
+
292
+ const cached = localStorage.getItem('skoyen_stop_id');
293
+ if (cached) {
294
+ const ok = await probeStopPlace(cached);
295
+ if (ok) return cached;
296
+ }
297
+
298
+ for (const term of SETTINGS.stopQueryTerms) {
299
+ const id = await geocodeStopPlace(term);
300
+ if (id) {
301
+ const ok = await probeStopPlace(id);
302
+ if (ok) {
303
+ localStorage.setItem('skoyen_stop_id', id);
304
+ return id;
305
+ }
306
+ }
307
+ }
308
+
309
+ throw new Error('Fant ikke StopPlace for Skøyen');
310
+ }
311
+
312
+ async function fetchDepartures(stopId) {
313
+ const query = `query($id:String!){
314
+ stopPlace(id:$id){
315
+ name
316
+ estimatedCalls(numberOfDepartures: 200, timeRange: 21600){
317
+ expectedDepartureTime
318
+ destinationDisplay{ frontText }
319
+ quay{ name publicCode }
320
+ serviceJourney{ journeyPattern{ line{ publicCode transportMode transportSubmode } } }
321
+ }
322
+ }
323
+ }`;
324
+
325
+ const res = await fetch('https://api.entur.io/journey-planner/v3/graphql', {
326
+ method: 'POST',
327
+ headers: { 'Content-Type': 'application/json', 'ET-Client-Name': SETTINGS.clientName },
328
+ body: JSON.stringify({ query, variables: { id: stopId } })
329
+ });
330
+
331
+ if (!res.ok) throw new Error('Entur HTTP ' + res.status);
332
+ const js = await res.json();
333
+ const calls = js?.data?.stopPlace?.estimatedCalls || [];
334
+ const placeName = js?.data?.stopPlace?.name || '';
335
+
336
+ const wantedArr = Array.from(new Set(SETTINGS.wantedLines.map(String)));
337
+ const items = calls
338
+ .map(c => ({
339
+ iso: c?.expectedDepartureTime,
340
+ line: c?.serviceJourney?.journeyPattern?.line?.publicCode || '',
341
+ mode: (c?.serviceJourney?.journeyPattern?.line?.transportMode || '').toLowerCase(),
342
+ submode: (c?.serviceJourney?.journeyPattern?.line?.transportSubmode || '').toLowerCase(),
343
+ destination: normDest(c?.destinationDisplay?.frontText || ''),
344
+ platform: (c?.quay?.publicCode || parsePlatform(c?.quay?.name || '')),
345
+ status: 'PÅ TID'
346
+ }))
347
+ .filter(x => wantedArr.some(w => String(x.line).startsWith(w)))
348
+ .sort((a, b) => new Date(a.iso) - new Date(b.iso));
349
+
350
+ const perKey = new Map();
351
+ for (const it of items) {
352
+ const base = wantedArr.find(w => String(it.line).startsWith(w)) || it.line;
353
+ const dirKey = it.destination;
354
+ const key = `${base}__${dirKey}`;
355
+ if (!perKey.has(key)) perKey.set(key, []);
356
+ const arr = perKey.get(key);
357
+ if (arr.length < Number(SETTINGS.perLineCount)) arr.push(it);
358
+ }
359
+
360
+ const kept = Array.from(perKey.values()).flat();
361
+ const rows = kept
362
+ .sort((a, b) => new Date(a.iso) - new Date(b.iso))
363
+ .map(x => ({
364
+ time: new Date(x.iso).toLocaleTimeString('no-NO', { hour: '2-digit', minute: '2-digit' }),
365
+ line: x.line,
366
+ destination: x.destination,
367
+ platform: x.platform || '',
368
+ status: x.status,
369
+ mode: x.mode,
370
+ submode: x.submode
371
+ }));
372
+
373
+ dbgSet([
374
+ ["StopPlace", stopId],
375
+ ["Navn", placeName],
376
+ ["Linjer", wantedArr.join(',')],
377
+ ["Per retning", String(SETTINGS.perLineCount)],
378
+ ["Viser", String(rows.length)]
379
+ ]);
380
+
381
+ return rows;
382
+ }
383
+
384
+ function getModeIcon(mode, submode) {
385
+ switch(mode) {
386
+ case 'bus':
387
+ return '<i data-feather="truck"></i>';
388
+ case 'rail':
389
+ case 'train':
390
+ return '<i data-feather="train"></i>';
391
+ case 'metro':
392
+ return '<i data-feather="layers"></i>';
393
+ case 'tram':
394
+ return '<i data-feather="grid"></i>';
395
+ case 'water':
396
+ case 'ferry':
397
+ return '<i data-feather="anchor"></i>';
398
+ default:
399
+ return '<i data-feather="circle"></i>';
400
+ }
401
+ }
402
+
403
+ function updateBoard(type, data) {
404
+ const container = document.getElementById(`${type}-container`) || document.getElementById('departures-container');
405
+ container.innerHTML = '';
406
+
407
+ if (!data || !data.length) {
408
+ const row = document.createElement('div');
409
+ row.className = 'w-full text-center text-sm text-red-300 py-4';
410
+ row.textContent = 'INGEN AVGANGER FOR VALGTE LINJER';
411
+ container.appendChild(row);
412
+ return;
413
+ }
414
+
415
+ data.forEach((item, index) => {
416
+ const row = document.createElement('div');
417
+ row.className = 'grid grid-cols-12 gap-3 mb-3 items-center text-center py-2 border-b border-[#333] last:border-b-0';
418
+
419
+ // Type icon
420
+ const iconCell = document.createElement('div');
421
+ iconCell.className = 'col-span-1 flex justify-center items-center';
422
+ iconCell.innerHTML = `<div class="split-flap w-7 h-10 md:w-8 md:h-12 mx-0.5 flex items-center justify-center">${getModeIcon(item.mode, item.submode)}</div>`;
423
+ row.appendChild(iconCell);
424
+
425
+ // Time
426
+ const timeCell = document.createElement('div');
427
+ timeCell.className = 'col-span-2 flex justify-center';
428
+ timeCell.appendChild(createSplitFlapDisplay(`${type}_t_${index}`, item.time, 5));
429
+ row.appendChild(timeCell);
430
+
431
+ // Line
432
+ const lineCell = document.createElement('div');
433
+ lineCell.className = 'col-span-2 flex justify-center';
434
+ lineCell.appendChild(createSplitFlapDisplay(`${type}_l_${index}`, item.line, 4));
435
+ row.appendChild(lineCell);
436
+
437
+ // Destination
438
+ const destCell = document.createElement('div');
439
+ destCell.className = 'col-span-4 flex justify-center';
440
+ destCell.appendChild(createSplitFlapDisplay(`${type}_d_${index}`, item.destination, 12));
441
+ row.appendChild(destCell);
442
+
443
+ // Platform
444
+ const platformCell = document.createElement('div');
445
+ platformCell.className = 'col-span-1 flex justify-center';
446
+ platformCell.appendChild(createSplitFlapDisplay(`${type}_p_${index}`, item.platform, 2));
447
+ row.appendChild(platformCell);
448
+
449
+ // Status
450
+ const statusCell = document.createElement('div');
451
+ statusCell.className = 'col-span-2 flex justify-center';
452
+ statusCell.appendChild(createSplitFlapDisplay(`${type}_s_${index}`, item.status, 6));
453
+ row.appendChild(statusCell);
454
+
455
+ container.appendChild(row);
456
+
457
+ // Animate the flaps with staggered timing
458
+ setTimeout(() => {
459
+ updateSplitFlapDisplay(`${type}_t_${index}`, item.time, 5);
460
+ updateSplitFlapDisplay(`${type}_l_${index}`, item.line, 4);
461
+ updateSplitFlapDisplay(`${type}_d_${index}`, item.destination, 12);
462
+ updateSplitFlapDisplay(`${type}_p_${index}`, item.platform, 2);
463
+ updateSplitFlapDisplay(`${type}_s_${index}`, item.status, 6);
464
+ }, 150 * index);
465
+ });
466
+
467
+ // Refresh feather icons after update
468
+ setTimeout(() => feather.replace(), 500);
469
+ }
470
+
471
+ function updateClock() {
472
+ const now = new Date();
473
+ const timeElement = document.getElementById('current-time');
474
+ const dateElement = document.getElementById('current-date');
475
+
476
+ if (timeElement) {
477
+ timeElement.textContent = now.toLocaleTimeString('no-NO');
478
+ }
479
+
480
+ if (dateElement) {
481
+ dateElement.textContent = now.toLocaleDateString('no-NO', {
482
+ weekday: 'long',
483
+ year: 'numeric',
484
+ month: 'long',
485
+ day: 'numeric'
486
+ });
487
+ }
488
+ }
489
+
490
+ setInterval(updateClock, 1000);
491
+ updateClock();
492
+
493
+ (async function main() {
494
+ const linesInput = document.getElementById('lines-input');
495
+ const perLineSelect = document.getElementById('perline-select');
496
+
497
+ if (linesInput) linesInput.value = SETTINGS.wantedLines.join(',');
498
+ if (perLineSelect) perLineSelect.value = String(SETTINGS.perLineCount);
499
+
500
+ let stopId;
501
+ try {
502
+ stopId = await resolveStopPlaceId();
503
+ const rows = await fetchDepartures(stopId);
504
+ updateBoard('departures', rows);
505
+ } catch (e) {
506
+ console.error(e);
507
+ dbgSet([["Error", String(e.message || e)]]);
508
+ updateBoard('departures', []);
509
+ }
510
+
511
+ const applyButton = document.getElementById('apply-btn');
512
+ if (applyButton) {
513
+ applyButton.addEventListener('click', async () => {
514
+ const rawLines = (document.getElementById('lines-input') || {}).value;
515
+ const perLine = Number((document.getElementById('perline-select') || {}).value) || 2;
516
+ const linesArray = Array.from(
517
+ new Set(
518
+ String(rawLines || '').split(/[ ,;]+/).map(s => s.trim()).filter(Boolean)
519
+ )
520
+ );
521
+
522
+ SETTINGS.wantedLines = linesArray.length ? linesArray : [];
523
+ SETTINGS.perLineCount = perLine;
524
+
525
+ if (stopId) {
526
+ const rows = await fetchDepartures(stopId);
527
+ updateBoard('departures', rows);
528
+ }
529
+ });
530
+ }
531
+
532
+ // Auto-refresh every 30 seconds
533
+ setInterval(async () => {
534
+ try {
535
+ if (stopId) {
536
+ const rows = await fetchDepartures(stopId);
537
+ updateBoard('departures', rows);
538
+ }
539
+ } catch (e) {
540
+ console.error(e);
541
+ }
542
+ }, 30000);
543
+ })();
544
+ </script>
545
+ </body>
546
  </html>
547
+