Compare commits

..

4 Commits

7 changed files with 520 additions and 156 deletions

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { ApiStationsBoard } from '@owlboard/owlboard-ts'; import type { ApiStationsBoard } from '@owlboard/owlboard-ts';
import { formatUkTime, estClass, calculateDelay } from '$lib/utils/time'; import { formatUkTime, estClass, delayClassFromTimePair } from '$lib/utils/time';
let { services }: { services: ApiStationsBoard.BoardService[] } = $props(); let { services }: { services: ApiStationsBoard.BoardService[] } = $props();
@@ -8,14 +8,30 @@
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`; `${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script> </script>
<div class="table-container"> <table class="departure-board">
<table class="departure-board"> <colgroup>
<col style="width:10%;" />
<!-- ID (Headcode) -->
<col style="width:17%;" />
<!-- ORIG (Origin) -->
<col style="width:17%;" />
<!-- DEST (Destination) -->
<col style="width:9%;" />
<!-- PLT (Platform) -->
<col style="width: 12%;" />
<!-- STA -->
<col style="width: 12%;" />
<!-- ETA/ATA -->
<col style="width: 12%;" />
<!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead> <thead>
<tr> <tr>
<th colspan="4" aria-hidden="true"></th> <th colspan="4" aria-hidden="true"></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th> <th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th>
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th> <th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th>
<th aria-hidden="true"></th>
</tr> </tr>
<tr> <tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th> <th scope="col"><abbr title="Headcode">ID</abbr></th>
@@ -26,92 +42,377 @@
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th> <th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="Scheduled">Sch</abbr></th> <th scope="col"><abbr title="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th> <th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
<th scope="col"><abbr title="+/- Time">+/-</abbr></th>
</tr> </tr>
</thead> </thead>
{#each services as service (getRowKey(service))} {#each services as service (getRowKey(service))}
<tbody> <tbody>
<tr class="service-row" class:serviceCancelled={service.c}> <tr
class="service-row"
class:serviceCancelled={service.c}
class:servicePass={service.wtp}
class:serviceNonPassenger={!service.ip}
>
<td class="id-cell">{service.h}</td> <td class="id-cell">{service.h}</td>
<td class="orig-cell">{service.og.t}</td> <td class="orig-cell"
<td class="dest-cell">{service.dt.t}</td> ><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></td
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}>{service.p}</td> >
<td class="dest-cell"
><abbr title={service.dt.n.toLocaleUpperCase()}>{service.dt.t}</abbr></td
>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}
>{service.p || '-'}</td
>
<!-- Handle different display for a passing train --> <!-- Handle different display for a passing train -->
{#if service.wtp} {#if service.wtp}
<td colspan="2">Pass</td> <td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</td> <td class="time-cell">{formatUkTime(service.wtp)}</td>
<td class="time-cell {estClass(service.atp, service.etp)}">{formatUkTime(service.atp || service.etp)}</td> <!-- If cancelled, show '-', otherwise check for RT or show time -->
<td
class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(
service.wtp,
service.atp || service.etp
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.wtp, service.atp || service.etp) === 'delay-rt'
? 'RT'
: formatUkTime(service.atp || service.etp)}
</span>
</td>
{:else} {:else}
<td class="time-cell">{formatUkTime(service.sta)}</td> <td class="time-cell">{formatUkTime(service.sta)}</td>
<td class="time-cell {estClass(service.ata, service.eta)}">{formatUkTime(service.ata || service.eta)}</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<td class="time-cell {estClass(service.atd, service.etd)}">{formatUkTime(service.atd || service.etd)}</td>
{/if}
{#if service} <!-- Arrival Actual/Expected -->
{@const delay = calculateDelay(service)} <td
<td class="delay-cell delay-{delay.type}">{delay.val}</td> class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(
service.sta,
service.ata || service.eta
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.sta, service.ata || service.eta) === 'delay-rt'
? 'RT'
: formatUkTime(service.ata || service.eta)}
</span>
</td>
<td class="time-cell">{formatUkTime(service.std)}</td>
<!-- Departure Actual/Expected -->
<td
class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(
service.std,
service.atd || service.etd
)}"
>
<span>
{service.c
? '-'
: delayClassFromTimePair(service.std, service.atd || service.etd) === 'delay-rt'
? 'RT'
: formatUkTime(service.atd || service.etd)}
</span>
</td>
{/if} {/if}
</tr> </tr>
{#if service.o}
<tr class="toc-coach-row">
<td colspan="8">
{service.o}
</td>
</tr>
{/if}
{#if service.c && service.cr?.r} {#if service.c && service.cr?.r}
<tr> <tr class="cancel-row">
<td colspan="9">{service.cr.r}</td> <td colspan="8">
{service.cr.r}
{#if service.cr.l}
{service.cr.n ? 'near' : 'at'}
{service.cr.l}
{/if}
</td>
</tr>
{/if}
{#if service.dr?.r}
<tr class="delay-row">
<td colspan="9">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? 'near' : 'at'}
{service.dr.l}
{/if}
</td>
</tr> </tr>
{/if} {/if}
</tbody> </tbody>
{/each} {/each}
</table>
</table>
</div>
<style> <style>
.table-container {
width: 100%;
}
.departure-board { .departure-board {
width: 100%; width: 100%;
margin: 0 auto; margin: 5px auto;
font-family: 'URW Gothic', sans-serif; border-collapse: collapse;
font-variant-numeric: tabular-nums; table-layout: fixed;
} }
.departure-board td {
font-family: 'Inconsolata Variable', monospace;
font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
font-variant-ligatures: additional-ligatures;
overflow: hidden;
}
thead,
.cancel-row td,
.delay-row td {
letter-spacing: -0.15ch;
}
thead {
position: sticky;
top: 0;
z-index: 2;
background: var(--color-bg-dark);
}
abbr {
text-decoration: none;
border-bottom: none;
cursor: help;
}
tbody tr:first-child td {
border-top: 5px solid transparent;
}
/* Row Logic */
.serviceCancelled { .serviceCancelled {
text-decoration: line-through; color: rgb(255, 131, 131);
} }
.id-cell{
font-family:'Courier New', Courier, monospace; .servicePass td {
text-align: center; opacity: 0.75;
font-weight: 200;
} }
.orig-cell, .dest-cell {
color: var(--location-yellow); .serviceNonPassenger td {
opacity: 0.5;
font-weight: 290;
font-style: italic;
}
/* Special Row Styles */
.cancel-row td,
.delay-row td {
font-size: 0.88rem;
text-align: left;
}
.cancel-row td[colspan],
.delay-row td[colspan] {
padding-left: 0ch;
}
.cancel-row td {
color: rgb(255, 131, 131);
font-weight: 400;
}
.delay-row td {
color: var(--delay-orange);
font-style: italic;
}
@media (min-width: 375px) {
.cancel-row td,
.delay-row td {
font-size: 1.08rem;
}
}
@media (min-width: 420px) {
.cancel-row td,
.delay-row td {
font-size: 1.12rem;
}
}
@media (min-width: 550px) {
.cancel-row td,
.delay-row td {
font-size: 1.18rem;
}
}
@media (min-width: 620px) {
.cancel-row td,
.delay-row td {
font-size: 1.20rem;
}
}
.toc-coach-row td {
font-family: 'URW Gothic', sans-serif;
font-size: 0.7rem;
padding: 0;
color: rgb(187, 187, 255);
}
@media(min-width: 400px) {
.toc-coach-row td {
font-size: 0.8rem;
}
}
@media(min-width: 550px) {
.toc-coach-row td {
font-size: 0.85rem;
}
}
/* Column Specifics */
.id-cell {
text-align: left;
font-weight: 400;
font-stretch: 85%;
filter: brightness(0.75);
}
@media (min-width: 400px) {
.id-cell {
font-stretch: 90%;
}
}
.orig-cell,
.dest-cell {
font-stretch: 80%;
font-weight: 400;
} }
.orig-cell { .orig-cell {
text-align: left; text-align: left;
color: var(--location-yellow);
} }
.dest-cell { .dest-cell {
text-align: right; text-align: right;
color: var(--location-yellow);
}
@media (min-width: 350px) {
.orig-cell,
.dest-cell {
font-stretch: 85%;
}
@media (min-width: 400px) {
.orig-cell,
.dest-cell {
font-stretch: 90%;
}
}
@media (min-width: 490px) {
.orig-cell,
.dest-cell {
font-stretch: 95%;
}
}
@media (min-width: 520px) {
.orig-cell,
.dest-cell {
font-stretch: 100%;
}
.orig-cell {
text-align: right;
padding-right: 7px;
}
.dest-cell {
text-align: left;
padding-left: 7px;
}
}
@media (min-width: 600px) {
.orig-cell,
.dest-cell {
letter-spacing: 0.05ch;
}
}
} }
.plt-cell { .plt-cell {
text-align: center; text-align: center;
font-weight: 410;
font-stretch: 100%;
} }
.platChange { @media (min-width: 525px) {
animation: 2s fast-pulse ease-in-out infinite; .plt-cell {
font-stretch: 110%;
} }
.platSup {
filter: opacity(0.5);
} }
.plt-cell.platSup {
font-weight: 200;
opacity: 0.3;
}
.plt-cell.platChange {
animation: fast-pulse 2s ease-out infinite;
}
.service-row.serviceCancelled .plt-cell {
text-decoration: line-through;
}
.pass-cell {
text-align: center;
font-stretch: 100%;
}
/* Colour orig and dest values when cancelled */
.service-row.serviceCancelled .orig-cell,
.service-row.serviceCancelled .dest-cell {
color: rgb(255, 131, 131);
}
.time-cell { .time-cell {
text-align: center; text-align: center;
font-stretch: 72%;
} }
.delay-cell { @media (min-width: 350px) {
text-align: center; .time-cell {
font-stretch: 77%;
} }
.delay-late {
color: var(--delay-orange);
} }
.delay-early { @media (min-width: 400px) {
.time-cell {
font-stretch: 82%;
}
}
@media (min-width: 525px) {
.time-cell {
font-stretch: 90%;
}
}
@media (min-width: 600px) {
.time-cell {
font-stretch: 100%;
}
}
/* RT Logic */
.time-cell.delay-rt span {
display: none;
}
.time-cell.delay-rt::after {
content: 'RT';
}
.time-cell.delay-early {
color: var(--early-blue); color: var(--early-blue);
} }
.time-cell.delay-late {
color: var(--delay-orange);
}
/* Time Types */
.est {
font-style: italic;
opacity: 0.75;
font-weight: 200;
}
.act {
font-weight: 600;
}
</style> </style>

View File

@@ -41,7 +41,7 @@
<style> <style>
.alert-card { .alert-card {
margin: 1rem 0; margin: 0;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
position: relative; position: relative;
@@ -49,6 +49,11 @@
margin-bottom: 0; margin-bottom: 0;
width: 95%; width: 95%;
max-width: 750px; max-width: 750px;
-webkit-tap-highlight-color: transparent;
}
.alert-card:active {
filter: brightness(1.2);
} }
.trigger { .trigger {
@@ -56,7 +61,7 @@
display: flex; display: flex;
border-radius: 8px; border-radius: 8px;
align-items: center; align-items: center;
padding: 0.4rem 1rem; padding: 0.1rem 1.05rem;
background: var(--alert-orange); background: var(--alert-orange);
color: white; color: white;
border: none; border: none;
@@ -64,13 +69,13 @@
text-align: left; text-align: left;
position: relative; position: relative;
z-index: 2; z-index: 2;
height: 46px; height: 40px;
transition: all 0.65s 0.2s; transition: all 0.65s 0.2s;
} }
.trigger.active { .trigger.active {
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
transition: all 0.1s 0s; transition: all 0.01s 0s;
} }
.warning-icon { .warning-icon {
@@ -105,7 +110,7 @@
.content { .content {
padding: 1rem; padding: 1rem;
position: absolute; position: absolute;
top: 46px; top: 40px;
border-radius: 0 0 8px 8px; border-radius: 0 0 8px 8px;
left: 0; left: 0;
right: 0; right: 0;

View File

@@ -129,7 +129,41 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
/* Fira Code - Variable with fallback */
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-VF.woff2') format('woff2-variations');
font-weight: 300 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Fira Code';
src: url('/type/fira-code/FiraCode-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* Inconsolata Variable */
@font-face {
font-family: 'Inconsolata Variable';
font-style: normal;
font-display: swap;
font-weight: 200 900;
font-stretch: 50% 200%;
src: url(/type/inconsolata/latin-wdth-normal.woff2) format('woff2-variations');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
:root { :root {
/* Fixed Heights */
--header-height: 80px;
--nav-height: 60px;
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
--color-accent: #3c6f79; --color-accent: #3c6f79;
@@ -159,7 +193,7 @@
opacity: 1; opacity: 1;
} }
50% { 50% {
opacity: 0.3; opacity: 0.5;
} }
} }

View File

@@ -1,6 +1,6 @@
import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts'; import type { ApiTrainsTrainDetails } from '@owlboard/owlboard-ts';
export const estClass = (act: any, est: any) => (!act && est ? 'est' : 'act'); export const estClass = (act: any, est: any) => (act ? 'act' : 'est');
/** /**
* Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds * Converts ISO/JSON time to UK-formatted HH:MM string, with optional (default off) seconds
@@ -10,10 +10,10 @@ export function formatUkTime(
dateStr: string | Date | undefined, dateStr: string | Date | undefined,
includeSeconds: boolean = false includeSeconds: boolean = false
): string { ): string {
if (!dateStr) return '--:--'; if (!dateStr) return '-';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr; const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
if (isNaN(date.getTime())) return '--:--'; if (isNaN(date.getTime())) return '-';
return date.toLocaleTimeString('en-GB', { return date.toLocaleTimeString('en-GB', {
hour: '2-digit', hour: '2-digit',
@@ -57,14 +57,20 @@ export function formatUkDateTime(
*/ */
interface DelayInput { interface DelayInput {
// Board types // Board types
sta?: string | null; std?: string | null; sta?: string | null;
eta?: string | null; etd?: string | null; std?: string | null;
ata?: string | null; atd?: string | null; eta?: string | null;
etd?: string | null;
ata?: string | null;
atd?: string | null;
// Journey types // Journey types
pta?: string | null; ptd?: string | null; pta?: string | null;
wta?: string | null; wtd?: string | null; ptd?: string | null;
atp?: string | null; wtp?: string | null; wta?: string | null;
wtd?: string | null;
atp?: string | null;
wtp?: string | null;
etp?: string | null; etp?: string | null;
} }
@@ -102,3 +108,27 @@ export function calculateDelay(loc: DelayInput): {
return { val: `${absDiff}E`, type: 'early' }; return { val: `${absDiff}E`, type: 'early' };
} }
} }
/**
* Accepts a pair of times (string or Date) and outputs a delay class 'delay-early', 'delay-late' or 'delay-rt'
* @param sched Scheduled Time (string, Date)
* @param act Actual Time (string, Date)
*/
export function delayClassFromTimePair(sched: any, act: any): string {
if (!sched || !act) return '';
const s = new Date(sched).getTime();
const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return '';
const diff = a - s;
const oneMinute = 60000;
// on-time if within one minute
if (Math.abs(diff) < oneMinute) {
return 'delay-rt';
}
return diff > 0 ? 'delay-late' : 'delay-early';
}

View File

@@ -147,14 +147,14 @@
<h1 class="viewport-guard-title">Narrow Gauge Detected</h1> <h1 class="viewport-guard-title">Narrow Gauge Detected</h1>
<p> <p>
Just as trains need the right track width, our data needs a bit more room to stay on the rails. Just as trains need the right track width, our data needs a bit more room to stay on the rails.
Please expand your view to at least 300px to view the app. Please expand your view to at least 320px to view the app.
</p> </p>
</div> </div>
<style> <style>
header { header {
top: 0; top: 0;
height: 80px; height: var(--header-height);
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
} }
.logo-link { .logo-link {
@@ -201,13 +201,12 @@
min-height: 100dvh; min-height: 100dvh;
box-sizing: border-box; box-sizing: border-box;
background-color: var(--color-bg-dark); background-color: var(--color-bg-dark);
background-image: radial-gradient(var(--color-bg-dark), var(--color-bg-light));
} }
nav { nav {
display: flex; display: flex;
bottom: 0; bottom: 0;
height: 60px; height: var(--nav-height);
box-shadow: var(--shadow-up); box-shadow: var(--shadow-up);
} }
@@ -320,7 +319,7 @@
display: none; display: none;
} }
@media (max-width: 299px) { @media (max-width: 319px) {
.viewport-guard { .viewport-guard {
display: block; display: block;
position: fixed; position: fixed;

View File

@@ -36,26 +36,29 @@
</script> </script>
<section class="board-wrapper"> <section class="board-wrapper">
{#if data.boardData.data.m?.length}
<StationAlertCard messages={data.boardData.data.m} />
{/if}
<div class="time-data"> <div class="time-data">
<span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span> <span class="time-loaded">Updated: {formatUkDateTime(data.boardData.producedAt, true)}</span>
<span class="time-now">{formatUkTime(now, true)}</span> <span class="time-now">{formatUkTime(now, true)}</span>
</div> </div>
{#if data.boardData.data.s?.length} {#if data.boardData.data.m?.length}
<StaffServicesTable services={data.boardData.data.s} /> <StationAlertCard messages={data.boardData.data.m} />
{/if}
{#if data.boardData.data.s?.length}
<div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} />
</div>
{/if} {/if}
<pre class="json-dump">{JSON.stringify(data.boardData.data.s, null, 2)}</pre>
</section> </section>
<style> <style>
.board-wrapper { .board-wrapper {
display: flex; display: flex;
height: calc(100dvh - var(--nav-height) - var(--header-height));
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
width: 100%; width: 100%;
margin: 0 auto; margin: 0 auto;
overflow: hidden;
gap: 0; gap: 0;
} }
@@ -82,21 +85,13 @@
font-weight: 600; font-weight: 600;
font-size: 1rem; font-size: 1rem;
} }
pre {
max-width: 100%; .service-list-wrapper {
white-space: pre-wrap; flex: 1;
word-wrap: break-word; overflow-y: auto;
box-sizing: border-box; height: calc(100dvh - 60px);
overflow-x: auto; width: 97%;
} max-width: 700px;
.json-dump { margin-bottom: 5px;
background: #222;
color: #0f0;
padding: 1rem;
border-radius: 4px;
font-size: 0.9rem;
max-height: 500px;
width: 95%;
margin: 1rem auto;
} }
</style> </style>

Binary file not shown.