Compare commits

...

3 Commits

7 changed files with 488 additions and 176 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, delayClassFromTimePair } 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,141 +8,411 @@
`${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`; `${s.r}${s.sta ?? ''}${s.std ?? ''}${s.wtp ?? ''}`;
</script> </script>
<table class="departure-board"> <table class="departure-board">
<thead> <colgroup>
<tr> <col style="width:10%;" />
<th colspan="4" aria-hidden="true"></th> <!-- ID (Headcode) -->
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Arrival">Arr</abbr></th> <col style="width:17%;" />
<th scope="colgroup" colspan="2" class="upper-head"><abbr title="Departure">Dep</abbr></th> <!-- ORIG (Origin) -->
</tr> <col style="width:17%;" />
<tr> <!-- DEST (Destination) -->
<th scope="col"><abbr title="Headcode">ID</abbr></th> <col style="width:9%;" />
<th scope="col"><abbr title="Origin">Orig</abbr></th> <!-- PLT (Platform) -->
<th scope="col"><abbr title="Destination">Dest</abbr></th> <col style="width: 12%;" />
<th scope="col"><abbr title="Platform">Plt</abbr></th> <!-- STA -->
<th scope="col"><abbr title="Scheduled">Sch</abbr></th> <col style="width: 12%;" />
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th> <!-- ETA/ATA -->
<th scope="col"><abbr title="Scheduled">Sch</abbr></th> <col style="width: 12%;" />
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th> <!-- STD -->
<col style="width: 11%;" />
<!-- ETD/ATD -->
</colgroup>
<thead>
<tr>
<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="Departure">Dep</abbr></th>
</tr>
<tr>
<th scope="col"><abbr title="Headcode">ID</abbr></th>
<th scope="col"><abbr title="Origin">Orig</abbr></th>
<th scope="col"><abbr title="Destination">Dest</abbr></th>
<th scope="col"><abbr title="Platform">Plt</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="Scheduled">Sch</abbr></th>
<th scope="col"><abbr title="Actual/Expected">Act</abbr></th>
</tr>
</thead>
{#each services as service (getRowKey(service))}
<tbody>
<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="orig-cell"
><abbr title={service.og.n.toLocaleUpperCase()}>{service.og.t}</abbr></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 -->
{#if service.wtp}
<td class="pass-cell" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(service.wtp)}</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}
<td class="time-cell">{formatUkTime(service.sta)}</td>
<!-- Arrival Actual/Expected -->
<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}
</tr> </tr>
</thead>
{#each services as service (getRowKey(service))} {#if service.o}
<tbody> <tr class="toc-coach-row">
<tr class="service-row" class:serviceCancelled={service.c} class:servicePass={service.wtp}> <td colspan="8">
<td class="id-cell">{service.h}</td> {service.o}
<td class="orig-cell">{service.og.t}</td> </td>
<td class="dest-cell">{service.dt.t}</td> </tr>
<td class="plt-cell" class:platSup={service.ps} class:platChange={service.pc}>{service.p}</td> {/if}
{#if service.c && service.cr?.r}
<!-- Handle different display for a passing train --> <tr class="cancel-row">
{#if service.wtp} <td colspan="8">
<td class="pass-cell" colspan="2">Pass</td> {service.cr.r}
<td class="time-cell">{formatUkTime(service.wtp)}</td> {#if service.cr.l}
<td class="time-cell {estClass(service.atp, service.etp)} {delayClassFromTimePair(service.wtp, service.atp || service.etp)}">{formatUkTime(service.atp || service.etp)}</td> {service.cr.n ? 'near' : 'at'}
{:else} {service.cr.l}
<td class="time-cell">{formatUkTime(service.sta)}</td> {/if}
<td class="time-cell {estClass(service.ata, service.eta)} {delayClassFromTimePair(service.sta, service.ata || service.eta)}">{formatUkTime(service.ata || service.eta)}</td> </td>
<td class="time-cell">{formatUkTime(service.std)}</td> </tr>
<td class="time-cell {estClass(service.atd, service.etd)} {delayClassFromTimePair(service.std, service.atd || service.etd)}">{formatUkTime(service.atd || service.etd)}</td> {/if}
{/if} {#if service.dr?.r}
</tr> <tr class="delay-row">
<td colspan="9">
{#if service.c && service.cr?.r} {service.dr.r}
<tr> {#if service.dr.l}
<td aria-hidden="true"></td> {service.dr.n ? 'near' : 'at'}
<td colspan="8"> {service.dr.l}
{service.cr.r} {/if}
{#if service.cr.l} </td>
{service.cr.n ? "near" : "at"} </tr>
{service.cr.l} {/if}
{/if} </tbody>
</td> {/each}
</tr> </table>
{/if}
{#if service.dr?.r}
<tr>
<td aria-hidden="true"></td>
<td colspan="8">
{service.dr.r}
{#if service.dr.l}
{service.dr.n ? "near" : "at"}
{service.dr.l}
{/if}
</td>
</tr>
{/if}
</tbody>
{/each}
</table>
<style> <style>
.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;
border-collapse: collapse; }
}
thead { .departure-board td {
position: sticky; font-family: 'Inconsolata Variable', monospace;
top: 0; font-size: clamp(1rem, 0.475rem + 2.8vw, 1.35rem);
z-index: 2; font-variant-ligatures: additional-ligatures;
background: var(--color-bg-dark); overflow: hidden;
} }
.serviceCancelled {
text-decoration: line-through; thead,
} .cancel-row td,
.servicePass { .delay-row td {
font-style: italic; letter-spacing: -0.15ch;
opacity: 0.5; }
}
.id-cell{ thead {
font-family:'Courier New', Courier, monospace; position: sticky;
text-align: center; top: 0;
} z-index: 2;
.orig-cell, .dest-cell { background: var(--color-bg-dark);
color: var(--location-yellow); }
}
.orig-cell { abbr {
text-align: left; text-decoration: none;
} border-bottom: none;
.dest-cell { cursor: help;
text-align: right; }
}
.plt-cell { tbody tr:first-child td {
text-align: center; border-top: 5px solid transparent;
} }
.platChange {
animation: 2s fast-pulse ease-in-out infinite; /* Row Logic */
} .serviceCancelled {
.platSup { color: rgb(255, 131, 131);
filter: opacity(0.5); }
}
.pass-cell { .servicePass td {
text-align: center; opacity: 0.75;
} font-weight: 200;
.time-cell { }
text-align: center;
} .serviceNonPassenger td {
.est { opacity: 0.5;
font-style: oblique; font-weight: 290;
opacity: 0.75; font-style: italic;
} }
.act {
font-weight: 600; /* Special Row Styles */
color: green; .cancel-row td,
} .delay-row td {
.delay-cell { font-size: 0.88rem;
text-align: center; text-align: left;
} }
.delay-late {
color: var(--delay-orange); .cancel-row td[colspan],
animation: 2s fast-pulse ease-in-out infinite; .delay-row td[colspan] {
} padding-left: 0ch;
.delay-early { }
color: var(--early-blue);
animation: 2s fast-pulse ease-in-out infinite; .cancel-row td {
} color: rgb(255, 131, 131);
</style> 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 {
text-align: left;
color: var(--location-yellow);
}
.dest-cell {
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 {
text-align: center;
font-weight: 410;
font-stretch: 100%;
}
@media (min-width: 525px) {
.plt-cell {
font-stretch: 110%;
}
}
.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 {
text-align: center;
font-stretch: 72%;
}
@media (min-width: 350px) {
.time-cell {
font-stretch: 77%;
}
}
@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);
}
.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>

View File

@@ -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,6 +129,36 @@
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 */ /* Fixed Heights */
--header-height: 80px; --header-height: 80px;
@@ -163,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',
@@ -56,15 +56,21 @@ export function formatUkDateTime(
* Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation * Specific type that can handle the TrainDetails.ServiceLocation and the ApiStationsBoard.BoardService types for delay calculation
*/ */
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;
// Journey types ata?: string | null;
pta?: string | null; ptd?: string | null; atd?: string | null;
wta?: string | null; wtd?: string | null;
atp?: string | null; wtp?: string | null; // Journey types
pta?: string | null;
ptd?: string | null;
wta?: string | null;
wtd?: string | null;
atp?: string | null;
wtp?: string | null;
etp?: string | null; etp?: string | null;
} }
@@ -79,13 +85,13 @@ export function calculateDelay(loc: DelayInput): {
type: string; type: string;
} { } {
const pairs = [ const pairs = [
// Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd) // Departure check (Board: atd/etd vs std | Journey: atd vs ptd/wtd)
{ actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd }, { actual: loc.atd || loc.etd, sched: loc.std || loc.ptd || loc.wtd },
// Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta) // Arrival check (Board: ata/eta vs sta | Journey: ata vs pta/wta)
{ actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta }, { actual: loc.ata || loc.eta, sched: loc.sta || loc.pta || loc.wta },
// Passing check // Passing check
{ actual: loc.atp || loc.etp, sched: loc.wtp } { actual: loc.atp || loc.etp, sched: loc.wtp }
]; ];
const match = pairs.find((p) => p.actual && p.sched); const match = pairs.find((p) => p.actual && p.sched);
@@ -109,20 +115,20 @@ export function calculateDelay(loc: DelayInput): {
* @param act Actual Time (string, Date) * @param act Actual Time (string, Date)
*/ */
export function delayClassFromTimePair(sched: any, act: any): string { export function delayClassFromTimePair(sched: any, act: any): string {
if (!sched || !act) return ''; if (!sched || !act) return '';
const s = new Date(sched).getTime(); const s = new Date(sched).getTime();
const a = new Date(act).getTime(); const a = new Date(act).getTime();
if (isNaN(s) || isNaN(a)) return ''; if (isNaN(s) || isNaN(a)) return '';
const diff = a - s; const diff = a - s;
const oneMinute = 60000; const oneMinute = 60000;
// on-time if within one minute // on-time if within one minute
if (Math.abs(diff) < oneMinute) { if (Math.abs(diff) < oneMinute) {
return 'delay-rt'; return 'delay-rt';
} }
return diff > 0 ? 'delay-late' : 'delay-early'; return diff > 0 ? 'delay-late' : 'delay-early';
} }

View File

@@ -147,7 +147,7 @@
<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>
@@ -319,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

@@ -44,9 +44,9 @@
<StationAlertCard messages={data.boardData.data.m} /> <StationAlertCard messages={data.boardData.data.m} />
{/if} {/if}
{#if data.boardData.data.s?.length} {#if data.boardData.data.s?.length}
<div class="service-list-wrapper"> <div class="service-list-wrapper">
<StaffServicesTable services={data.boardData.data.s} /> <StaffServicesTable services={data.boardData.data.s} />
</div> </div>
{/if} {/if}
</section> </section>
@@ -90,7 +90,8 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
height: calc(100dvh - 60px); height: calc(100dvh - 60px);
width: 100%; width: 97%;
max-width: 700px;
margin-bottom: 5px; margin-bottom: 5px;
} }
</style> </style>

Binary file not shown.