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
Raw
History Blame Contribute Delete
20.1 kB
<!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>