splitflap-express / index.html
BruceBanners's picture
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.
1c84ee3 verified
<!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 – Split-Flap Avganger</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
body {
font-family: 'JetBrains Mono', monospace;
background: #0a0a0a;
color: #fff;
min-height: 100vh;
overflow-x: hidden;
}
.split-flap-board {
background: linear-gradient(135deg, #1a1a1a 0%, #151515 100%);
border: 1px solid #2a2a2a;
border-radius: 8px;
box-shadow:
0 4px 20px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
position: relative;
overflow: hidden;
}
.split-flap-board::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
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");
pointer-events: none;
opacity: 0.15;
}
.split-flap-cell {
position: relative;
background: #1e1e1e;
border: 1px solid #333;
border-radius: 2px;
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.5),
0 1px 0 rgba(255, 255, 255, 0.05);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
perspective: 200px;
}
.split-flap-character {
position: absolute;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ffcc00;
font-weight: 700;
text-shadow: 0 0 2px rgba(255, 204, 0, 0.5);
backface-visibility: hidden;
transform-style: preserve-3d;
transition: none;
}
.split-flap-character.flipping {
animation: flip 0.15s ease-out forwards;
}
@keyframes flip {
0% {
transform: rotateX(0deg);
}
50% {
transform: rotateX(-90deg);
}
100% {
transform: rotateX(0deg);
}
}
.split-flap-character.numeric {
animation-duration: 0.12s;
}
.header-cell {
color: #ffcc00;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-on-time {
color: #aaffaa !important;
}
.flap-icon {
filter: brightness(1.2) contrast(1.2);
}
.board-grid {
display: grid;
grid-template-columns: 0.8fr 1.2fr 1.2fr 2.8fr 0.8fr 1.2fr;
gap: 4px;
padding: 8px;
}
.no-departures {
grid-column: 1 / -1;
display: flex;
justify-content: center;
align-items: center;
height: 48px;
}
@media (max-width: 768px) {
.board-grid {
grid-template-columns: 0.8fr 1.2fr 1.2fr 2fr 0.8fr 1.2fr;
gap: 2px;
padding: 6px;
}
.split-flap-cell {
height: 32px;
font-size: 14px;
}
}
@media (max-width: 640px) {
.board-grid {
grid-template-columns: 0.8fr 1.2fr 1fr 1.6fr 0.8fr 1fr;
}
}
</style>
</head>
<body class="flex flex-col items-center justify-center p-4 md:p-6 bg-gray-900">
<div class="w-full max-w-6xl mx-auto">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-2xl md:text-3xl font-bold text-yellow-400 tracking-wide">SKØYEN STASJON</h1>
<div id="dbg" class="hidden md:flex items-center gap-2 text-xs text-gray-400"></div>
</div>
<div class="mb-6 flex flex-col items-center text-orange-300">
<div class="flex items-center gap-2">
<i data-feather="clock" class="w-4 h-4"></i>
<span id="current-time" class="text-base md:text-lg font-mono">00:00:00</span>
</div>
<div class="text-xs text-gray-400 mt-1"><span id="current-date">&nbsp;</span></div>
</div>
<div class="w-full max-w-6xl mx-auto mb-6">
<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-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" />
</label>
<label class="flex flex-col">
<span class="mb-1 text-gray-300">Antall per retning</span>
<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">
<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-4 py-2 rounded bg-yellow-500 text-black font-bold hover:bg-yellow-400 active:bg-yellow-600 transition-colors">Oppdater</button>
</div>
</div>
<div class="split-flap-board">
<div class="board-grid mb-3">
<div class="header-cell">TYPE</div>
<div class="header-cell">TID</div>
<div class="header-cell">LINJE</div>
<div class="header-cell">DESTINASJON</div>
<div class="header-cell">PL</div>
<div class="header-cell">STATUS</div>
</div>
<div id="departures-container"></div>
</div>
</div>
<script type="module">
feather.replace();
class SplitFlap {
constructor(el, initialVal) {
this.el = el;
this.currentVal = initialVal || ' ';
this.isNumeric = /^\d$/.test(this.currentVal);
this.createCharacterElement();
}
createCharacterElement() {
this.el.innerHTML = '';
const charEl = document.createElement('div');
charEl.className = `split-flap-character ${this.isNumeric ? 'numeric' : ''}`;
charEl.textContent = this.currentVal;
this.el.appendChild(charEl);
this.charEl = charEl;
}
setValue(newVal, delay = 0) {
if (newVal === this.currentVal) return;
this.isNumeric = /^\d$/.test(newVal);
setTimeout(() => {
this.charEl.classList.add('flipping');
setTimeout(() => {
this.currentVal = newVal;
this.charEl.textContent = newVal;
this.charEl.className = `split-flap-character ${this.isNumeric ? 'numeric' : ''}`;
setTimeout(() => this.charEl.classList.remove('flipping'), 10);
}, this.isNumeric ? 60 : 75);
}, delay);
}
}
const splitFlapDisplays = {};
function createSplitFlapDisplay(id, val, len, colType = 'text') {
const container = document.createElement('div');
container.className = 'flex justify-center';
for (let i = 0; i < len; i++) {
const ch = (val && val[i]) || ' ';
const cell = document.createElement('div');
const size = colType === 'icon' ? 'w-6 h-8' : 'w-6 h-8';
cell.className = `split-flap-cell ${size} mx-0.5`;
container.appendChild(cell);
splitFlapDisplays[`${id}_${i}`] = new SplitFlap(cell, ch);
}
return container;
}
function updateSplitFlapDisplay(id, val, len, colType = 'text') {
const padded = (val || '').padEnd(len, ' ');
const isNumericCol = colType === 'numeric';
for (let i = 0; i < len; i++) {
const delay = i * (isNumericCol ? 30 : 40);
splitFlapDisplays[`${id}_${i}`]?.setValue(padded[i], delay);
}
}
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="px-2 py-1 bg-gray-800 rounded text-xs border border-gray-700">${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 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>';
case 'rail':
case 'train':
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>';
case 'metro':
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>';
case 'tram':
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>';
case 'water':
case 'ferry':
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>';
default:
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>';
}
}
function updateBoard(type, data) {
const cont = document.getElementById(`${type}-container`) || document.getElementById('departures-container');
cont.innerHTML = '';
if (!data || !data.length) {
const noDepartures = document.createElement('div');
noDepartures.className = 'no-departures';
noDepartures.appendChild(createSplitFlapDisplay('no_departures', 'NO DEPARTURES', 12));
cont.appendChild(noDepartures);
setTimeout(() => updateSplitFlapDisplay('no_departures', 'NO DEPARTURES', 12), 100);
return;
}
data.forEach((item, i) => {
const row = document.createElement('div');
row.className = 'board-grid items-center';
// Icon column
const iconCol = document.createElement('div');
iconCol.className = 'flex justify-center items-center';
const iconContainer = document.createElement('div');
iconContainer.className = 'split-flap-cell w-6 h-8 mx-0.5 flex items-center justify-center';
iconContainer.innerHTML = getModeIcon(item.mode, item.submode);
iconCol.appendChild(iconContainer);
row.appendChild(iconCol);
// Time column
const timeCol = document.createElement('div');
timeCol.appendChild(createSplitFlapDisplay(`${type}_t_${i}`, item.time, 5, 'numeric'));
row.appendChild(timeCol);
// Line column
const lineCol = document.createElement('div');
lineCol.appendChild(createSplitFlapDisplay(`${type}_l_${i}`, item.line, 4, 'numeric'));
row.appendChild(lineCol);
// Destination column
const destCol = document.createElement('div');
destCol.appendChild(createSplitFlapDisplay(`${type}_d_${i}`, item.destination, 16));
row.appendChild(destCol);
// Platform column
const platformCol = document.createElement('div');
platformCol.appendChild(createSplitFlapDisplay(`${type}_p_${i}`, item.platform, 2, 'numeric'));
row.appendChild(platformCol);
// Status column
const statusCol = document.createElement('div');
const statusDisplay = createSplitFlapDisplay(`${type}_s_${i}`, item.status, 6);
if (item.status === 'PÅ TID') {
statusDisplay.classList.add('status-on-time');
}
statusCol.appendChild(statusDisplay);
row.appendChild(statusCol);
cont.appendChild(row);
// Update with staggered animation
setTimeout(() => {
updateSplitFlapDisplay(`${type}_t_${i}`, item.time, 5, 'numeric');
updateSplitFlapDisplay(`${type}_l_${i}`, item.line, 4, 'numeric');
updateSplitFlapDisplay(`${type}_d_${i}`, item.destination, 16);
updateSplitFlapDisplay(`${type}_p_${i}`, item.platform, 2, 'numeric');
updateSplitFlapDisplay(`${type}_s_${i}`, item.status, 6);
}, i * 200);
});
}
function updateClock() {
const now = new Date();
const t = document.getElementById('current-time');
const d = document.getElementById('current-date');
if (t) t.textContent = now.toLocaleTimeString('no-NO');
if (d) d.textContent = now.toLocaleDateString('no-NO', {weekday: 'long', year: 'numeric', month: 'long', day: 'numeric'});
}
setInterval(updateClock, 1000);
updateClock();
(async function main() {
const li = document.getElementById('lines-input');
if (li) li.value = SETTINGS.wantedLines.join(',');
const ps = document.getElementById('perline-select');
if (ps) ps.value = String(SETTINGS.perLineCount);
let stopId;
try {
stopId = await resolveStopPlaceId();
const rows = await fetchDepartures(stopId);
updateBoard('departures', rows);
} catch (e) {
console.error(e);
dbgSet([["Error", String(e.message || e)]]);
updateBoard('departures', []);
}
const apply = document.getElementById('apply-btn');
if (apply) {
apply.addEventListener('click', async () => {
const raw = (document.getElementById('lines-input') || {}).value;
const per = Number((document.getElementById('perline-select') || {}).value) || 2;
const arr = Array.from(new Set(String(raw || '').split(/[ ,;]+/).map(s => s.trim()).filter(Boolean)));
SETTINGS.wantedLines = arr.length ? arr : [];
SETTINGS.perLineCount = per;
if (stopId) {
const rows = await fetchDepartures(stopId);
updateBoard('departures', rows);
}
});
}
setInterval(async () => {
try {
if (stopId) {
const rows = await fetchDepartures(stopId);
updateBoard('departures', rows);
}
} catch (e) {
console.error(e);
}
}, 30000);
})();
</script>
</body>
</html>