Spaces:
Running
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.
1c84ee3
verified
| <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 ; | |
| } | |
| .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"> </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> | |