Compare commits

...

3 Commits

Author SHA1 Message Date
3b91fad590 Implement 'internal submit button' to the 'Textbox' component. Remove separate submit to the Headcode search card.
Adjust the hover and active styles of the 'TrainService' expandable cards.
2026-05-05 15:55:51 +01:00
6a857c2d64 Extend data in the schedule box 2026-05-04 21:49:59 +01:00
1c4c7ccabc Add JetBrains Mono font and adjust styling of the schedule. 2026-05-04 20:14:00 +01:00
22 changed files with 551 additions and 205 deletions

View File

@@ -2,6 +2,8 @@
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
import { IconChevronRightFilled } from '@tabler/icons-svelte';
interface Props extends HTMLInputAttributes { interface Props extends HTMLInputAttributes {
value?: string; value?: string;
label?: string; label?: string;
@@ -9,6 +11,7 @@
type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url'; type?: 'text' | 'password' | 'email' | 'number' | 'search' | 'tel' | 'url';
error?: string; error?: string;
uppercase?: boolean; uppercase?: boolean;
onsubmit?: (val: string) => void | Promise<void>;
[key: string]: any; [key: string]: any;
} }
@@ -19,10 +22,18 @@
type = 'text', type = 'text',
error = '', error = '',
uppercase = false, uppercase = false,
onsubmit,
...rest ...rest
}: Props = $props(); }: Props = $props();
let isFocussed = $state(false); let isFocussed = $state(false);
const handleSubmit = (e: Event) => {
e.preventDefault();
if (onsubmit && value) {
onsubmit(value);
}
}
</script> </script>
<div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}> <div class="input-wrapper" class:focussed={isFocussed} class:has-error={!!error}>
@@ -30,6 +41,7 @@
<label for="adaptive-input">{label}</label> <label for="adaptive-input">{label}</label>
{/if} {/if}
<form onsubmit={handleSubmit} class="input-container">
<input <input
id="adaptive-input" id="adaptive-input"
class:all-caps={uppercase} class:all-caps={uppercase}
@@ -41,6 +53,19 @@
{...rest} {...rest}
/> />
{#if onsubmit}
<button
type="submit"
class="submit-icon"
transition:fade
disabled={!value}
aria-label="Submit"
>
<IconChevronRightFilled />
</button>
{/if}
</form>
{#if error} {#if error}
<span class="error-message" transition:fade>{error}</span> <span class="error-message" transition:fade>{error}</span>
{/if} {/if}
@@ -50,11 +75,22 @@
.input-wrapper { .input-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 0.4rem;
width: 100%; width: 100%;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
} }
.input-container {
position: relative;
display: flex;
align-items: center;
width: 100%;
background-color: var(--color-title);
border-radius: 5000px;
transition: all 0.2s;
overflow: hidden;
}
label { label {
font-size: 0.9rem; font-size: 0.9rem;
font-weight: 400; font-weight: 400;
@@ -62,19 +98,46 @@
} }
input { input {
width: 100%;
background: transparent;
min-height: 40px; min-height: 40px;
padding: 0 16px; height: 100%;
background-color: var(--color-title); line-height: normal;
border: 2px solid transparent; padding: 0 48px;
border-radius: 20px; border: none;
color: var(--color-bg-dark); color: var(--color-bg-dark);
font-size: 1.2rem; font-size: 1.2rem;
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
outline: none; outline: none;
text-align: center; text-align: center;
box-shadow: var(--shadow-std);
} }
.submit-icon {
position: absolute;
background: transparent;
right: 0;
border: none;
color: var(--color-bg-dark);
cursor: pointer;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.75;
transition: opacity 0.2s, transform 0.2s;
}
.submit-icon:hover:not(:disabled) {
opacity: 1;
transform: translateX(2px);
}
.submit-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.all-caps { .all-caps {
text-transform: uppercase; text-transform: uppercase;
} }

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts'; import type { ApiPisObject, ApiTrainsTrainByHeadcode } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient'; import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { quintOut } from 'svelte/easing'; import { quintOut } from 'svelte/easing';
import { formatUkTime, calculateDelay } from '$lib/utils/time'; import { formatUkTime, calculateDelay } from '$lib/utils/time';
import TocStyle from '$lib/components/ui/TocStyle.svelte'; import TocStyle from '$lib/components/ui/TocStyle.svelte';
import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte'; import TiplocConverter from '$lib/components/ui/TiplocConverter.svelte';
import { IconCircleArrowDownFilled } from '@tabler/icons-svelte'; import { IconChevronDownFilled } from '@tabler/icons-svelte';
let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props(); let { service }: { service: ApiTrainsTrainByHeadcode.TrainByHeadcodeResponse } = $props();
let isExpanded = $state(false); let isExpanded = $state(false);
let loadingDetails = $state(false); let loadingDetails = $state(false);
@@ -46,9 +46,54 @@
} }
const estClass = (act, est) => (!act && est ? 'est' : 'act'); const estClass = (act, est) => (!act && est ? 'est' : 'act');
const activityMap: Record<string, string> = {
'-D': 'Vehicles detatched',
'-T': 'Vehicles attached & detached',
'-U': 'Vehicles attached',
AE: 'Assist locomotive attached',
C: 'Traincrew change only',
D: 'Set down only',
E: 'Stops for examination',
K: 'Passenger count point',
KC: 'Ticket collection point',
KE: 'Ticket examination point',
KF: 'First class ticket examination point',
KS: 'Selective ticket examination point',
L: 'Locomotive changed',
N: 'Unadvertised stop',
OP: 'Operational stop only',
OR: 'Locomotive attached on rear',
R: 'Request stop only',
RM: 'Train changes direction',
RR: 'Locomotive run round',
S: 'Staff only stop',
TW: 'Stops for token/staff/tablet only',
U: 'Pick up only',
W: 'Watering coaches',
X: 'Passes another train'
};
const activityRegex = new RegExp(
Object.keys(activityMap)
.sort((a, b) => b.length - a.length) // Longest codes first
.map((key) => key.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')) // Escape special chars
.join('|'),
'g'
);
const getRelevantActivities = (act: string) => {
if (!act) return [];
// Find all matches in the string
const matches = act.match(activityRegex) || [];
// Map to labels and remove duplicates
return [...new Set(matches)].map((code) => activityMap[code]);
};
</script> </script>
<div class="train-service"> <div class="train-service" class:isExpanded={isExpanded}>
<button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}> <button class="summary" onclick={toggleExpand} type="button" aria-expanded={isExpanded}>
{#if loadingDetails} {#if loadingDetails}
<div class="loading-state"><div class="loading-spinner"></div></div> <div class="loading-state"><div class="loading-spinner"></div></div>
@@ -68,7 +113,7 @@
{service.dt} {service.dt}
</div> </div>
<div class="arrow" class:expanded={isExpanded}> <div class="arrow" class:expanded={isExpanded}>
<IconCircleArrowDownFilled color={"var(--color-title)"} size={25} /> <IconChevronDownFilled color={'var(--color-title)'} size={25} />
</div> </div>
</div> </div>
</button> </button>
@@ -86,7 +131,6 @@
{#if details.header.cl} {#if details.header.cl}
{details.header.cn ? ' near ' : ' at '} {details.header.cn ? ' near ' : ' at '}
<TiplocConverter code={details.header.cl} /> <TiplocConverter code={details.header.cl} />
<!-- CONSIDER WRAPPING IN A COMPONENT THAT CONVERTS TO THE LOCATION NAME RATHER THAN TIPLOC -->
{/if} {/if}
</span> </span>
{/if} {/if}
@@ -101,9 +145,14 @@
{/if} {/if}
</span> </span>
{/if} {/if}
<div class="pis-detail"> {#if details.pis}
<!-- PIS Data Here --> <div class="pis-detail">
</div> {details.pis.code}
</div>
{/if}
</div>
<div class="color-key">
<p class="tpl-stop">Times in yellow are estimates</p>
</div> </div>
<div class="schedule-table-container"> <div class="schedule-table-container">
<table class="schedule-table"> <table class="schedule-table">
@@ -117,40 +166,54 @@
<th>Location</th> <th>Location</th>
<th>Plat</th> <th>Plat</th>
<th>Sch</th> <th>Sch</th>
<th><span class="tpl-cell">Est</span>/Act</th> <th>Act</th>
<th>Sch</th> <th>Sch</th>
<th><span class="tpl-cell">Est</span>/Act</th> <th>Act</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> {#each details.locations as loc}
{#each details.locations as loc} <tbody class="location-group">
<tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}> <tr class:pass-loc={loc.r === 'PASS'} class:can-loc={loc.can}>
<td class="tpl-cell">{loc.t}</td> <td class="tpl-cell" class:tpl-stop={loc.r != 'PASS'}>
<td class="plat-cell">{loc.p}</td> {loc.t}
</td>
<td class="plat-cell cell-divider-right" class:plat-change={loc.pc}>{loc.p}</td>
{#if loc.r == 'PASS'} {#if loc.r == 'PASS'}
<td class="time-cell" colspan="2">Pass</td> <td class="time-cell cell-divider-right" colspan="2">Pass</td>
<td class="time-cell">{formatUkTime(loc.wtp)}</td> <td class="time-cell">{formatUkTime(loc.wtp)}</td>
<td class="time-cell {estClass(loc.atp, loc.etp)}" <td class="time-cell {estClass(loc.atp, loc.etp)}"
>{formatUkTime(loc.atp || loc.etp || '--')}</td >{formatUkTime(loc.atp || loc.etp || '--')}</td
> >
{:else} {:else}
<td class="time-cell">{formatUkTime(loc.pta || loc.wta || '--')}</td> <td class="time-cell">{formatUkTime(loc.pta || loc.wta || '--')}</td>
<td class="time-cell {estClass(loc.ata, loc.eta)}" <td class="time-cell cell-divider-right {estClass(loc.ata, loc.eta)}"
>{formatUkTime(loc.ata || loc.eta || '--')}</td >{formatUkTime(loc.ata || loc.eta || '--')}</td
> >
<td class="time-cell">{formatUkTime(loc.ptd || loc.wtd || '--')}</td> <td class="time-cell">{formatUkTime(loc.ptd || loc.wtd || '--')}</td>
<td class="time-cell {estClass(loc.atd, loc.etd)}" <td class="time-cell cell-divider-right {estClass(loc.atd, loc.etd)}"
>{formatUkTime(loc.atd || loc.etd || '--')}</td >{formatUkTime(loc.atd || loc.etd || '--')}</td
> >
{/if} {/if}
{#if loc} {#if loc}
{@const delay = calculateDelay(loc)} {@const delay = calculateDelay(loc)}
<td class="delay-{delay.type}">{delay.val}</td> <td class="delay-{delay.type}">{delay.val}</td>
{/if} {/if}
</tr> </tr>
{/each} {#if loc.act && getRelevantActivities(loc.act).length > 0}
</tbody> <tr class="activity-row">
<td colspan="7">
<div class="activity-container">
{#each getRelevantActivities(loc.act) as note}
<span class="activity-tag">{note}</span>
{/each}
</div>
</td>
</tr>
{/if}
</tbody>{/each}
</table> </table>
</div> </div>
</div> </div>
@@ -158,29 +221,49 @@
</div> </div>
<style> <style>
/*
Main container
*/
.train-service { .train-service {
background-color: var(--color-accent); background-color: var(--color-accent);
width: 100%; width: 100%;
border-radius: 4px; max-width: 460px;
border-radius: 12px;
box-shadow: var(--shadow-std); box-shadow: var(--shadow-std);
overflow: hidden; overflow: visible;
font-family: 'URW Gothic', sans-serif; font-family: 'URW Gothic', sans-serif;
transition: 0.2s all; transition: 0.2s all;
filter: brightness(1.1); filter: brightness(1.1);
-webkit-tap-highlight-color: transparent;
}
.train-service:active {
filter: brightness(1.2);
}
.train-service:active .arrow {
filter: brightness(0.2);
} }
@media (hover: hover) { @media (hover: hover) {
.train-service:hover { .train-service:not(.isExpanded):hover {
filter: brightness(1.2); filter: brightness(1.2);
} }
.summary:hover .arrow {
filter: brightness(0.2);
}
} }
/*
Summary Header
*/
.summary { .summary {
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0 1rem;
min-height: 48px; min-height: 48px;
border: none; border: none;
background: transparent; background: transparent;
@@ -189,6 +272,198 @@
gap: 0.5rem; gap: 0.5rem;
} }
.operator-summary {
flex-shrink: 0;
}
.main-text-summary {
display: flex;
flex-grow: 1;
align-items: center;
gap: 0.5rem;
}
.time-summary {
font-size: 0.75rem;
font-weight: 700;
color: var(--color-brand);
}
.location-summary {
text-transform: uppercase;
font-weight: 500;
font-size: 0.75rem;
letter-spacing: 0.02em;
color: var(--color-title);
}
.to-summary {
font-size: 0.8rem;
font-style: oblique;
text-transform: lowercase;
}
.arrow {
padding: 0;
margin: 0 0 0 auto;
height: 25px;
transition: all 0.3s;
}
.expanded {
transform: rotateX(180deg);
}
.can-all {
color: red;
}
/*
Box Extention
*/
.box-ext {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.detail-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
width: 100%;
}
.cancel-reason,
.delay-reason {
display: block;
padding: 4px 8px;
width: 95%;
font-size: 1rem;
font-weight: 500;
animation: cancel-pulse 2s ease-in-out infinite;
}
.cancel-reason {
color: var(--cancel-red);
}
.delay-reason {
color: rgb(255, 119, 0);
}
/*
Schedule Table
*/
.color-key,
.color-key p {
text-align: center;
width: 100%;
padding: 0 0 0.2rem 0;
margin: 0;
}
.schedule-table-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
padding-bottom: 0.1rem;
}
.schedule-table {
width: 95%;
border-collapse: collapse;
font-family: 'URW Gothic', 'Inter', sans-serif;
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
letter-spacing: -0.02ch;
transition: all 0.5s;
font-weight: 500;
}
.location-group:nth-of-type(even) {
background-color: rgba(255, 255, 255, 0.04);
}
.cell-divider-right {
border-right: 1px solid rgba(255, 255, 255, 0.1);
}
th,
td {
text-align: center;
padding: 6px 4px;
}
.activity-row td {
padding: 0 0 10px 0;
text-align: left;
font-size: 0.75rem;
letter-spacing: 0.01em;
}
.activity-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px;
}
.activity-tag {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.05);
padding: 2px 6px;
border-radius: 4px;
}
.tpl-cell {
color: var(--color-title);
text-align: left;
}
.tpl-stop {
color: var(--location-yellow);
}
.plat-change {
animation: fast-pulse 2s ease-out infinite;
}
.pass-loc td {
color: var(--color-title);
font-style: oblique;
opacity: 0.5;
}
.can-loc {
text-decoration: line-through;
}
.est {
color: var(--location-yellow);
font-style: oblique;
}
td.delay-late {
color: var(--delay-orange);
font-weight: 600;
animation: pulse 2s ease-out infinite;
}
td.delay-early {
color: var(--early-blue);
font-weight: 600;
animation: pulse 2s ease-out infinite;
}
/*
Loading State
*/
.loading-state { .loading-state {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -216,157 +491,52 @@
z-index: 3; z-index: 3;
} }
/*
Responsivity
*/
@media (min-width: 330px) {
.time-summary,
.location-summary {
font-size: 0.9rem;
}
.activity-row td {
font-size: 0.8rem;
}
}
@media (min-width: 340px) {
.schedule-table {
font-size: 0.9rem;
}
}
@media (min-width: 360px) {
.time-summary {
font-size: 1.1rem;
}
.location-summary {
font-size: 1rem;
}
.schedule-table {
font-size: 0.99rem;
}
.activity-row td {
font-size: 0.9rem;
}
}
@media (min-width: 420px) {
.schedule-table {
font-size: 1.1rem;
}
}
/*
KEYFRAMES
*/
@keyframes load-spin { @keyframes load-spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
.operator-summary {
flex-shrink: 0;
}
.main-text-summary {
display: flex;
flex-grow: 1;
align-items: center;
gap: 0.5rem;
}
.time-summary {
font-size: 1.1rem;
font-weight: 700;
color: var(--color-brand);
}
.location-summary {
text-transform: uppercase;
font-weight: 500;
font-size: 1.1rem;
letter-spacing: 0.02em;
color: var(--color-title);
}
.to-summary {
font-size: 0.8rem;
font-style: oblique;
text-transform: lowercase;
}
.can-all {
color: red;
}
.arrow {
padding: 0;
margin: 0;
margin-left: auto;
height: 25px;
transition: all 0.3s;
}
.expanded {
transform: rotateX(180deg);
}
.box-ext {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
}
.detail-head {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
text-align: center;
width: 100%;
}
.cancel-reason,
.delay-reason {
display: block;
padding: 4px 8px;
width: 95%;
}
.cancel-reason {
color: rgb(255, 0, 0);
font-weight: 500;
animation: cancel-pulse 2s ease-in-out infinite;
}
.delay-reason {
color: rgb(255, 119, 0);
font-weight: 500;
animation: cancel-pulse 2s ease-in-out infinite;
}
@keyframes cancel-pulse {
0% {
opacity: 1;
text-shadow: 0 0 0px rgb(255, 0, 0);
}
50% {
opacity: 0.8;
text-shadow: 0 0 5px rgba(255, 0, 0, 0.2);
}
100% {
opacity: 1;
text-shadow: 0 0 0px rgb(255, 0, 0);
}
}
.schedule-table-container {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
}
.schedule-table {
width: 95%;
max-width: 375px;
padding-bottom: 1rem;
}
th,
td {
text-align: center;
}
.tpl-cell {
color: yellow;
text-align: left;
}
.pass-loc {
color: var(--color-title);
opacity: 0.75;
font-style: oblique;
}
.can-loc {
text-decoration: line-through;
}
.est {
color: yellow;
opacity: 0.5;
}
.act {
color: white;
}
.delay-late {
color: red;
}
.delay-early {
color: blue;
}
</style> </style>

View File

@@ -52,7 +52,7 @@
.card { .card {
background: var(--color-accent); background: var(--color-accent);
position: relative; position: relative;
border-radius: 20px; border-radius: 12px;
overflow: visible; overflow: visible;
width: 95%; width: 95%;
max-width: 600px; max-width: 600px;

View File

@@ -6,8 +6,7 @@
let headcode = $state(''); let headcode = $state('');
function handleSearch(e: SubmitEvent) { function handleSearch(headcode: string) {
e.preventDefault();
if (!headcode.trim()) return; if (!headcode.trim()) return;
const searchParams = new URLSearchParams(); const searchParams = new URLSearchParams();
@@ -18,10 +17,9 @@
</script> </script>
<BaseCard header={'Search Train & PIS'}> <BaseCard header={'Search Train & PIS'}>
<form onsubmit={handleSearch} class="card-content"> <div class="card-content">
<Textbox placeholder="Enter Headcode" bind:value={headcode} maxLength={4} /> <Textbox placeholder="Enter Headcode" bind:value={headcode} maxLength={4} onsubmit={handleSearch} />
<Button type="submit" disabled={!headcode}>Search</Button> </div>
</form>
</BaseCard> </BaseCard>
<style> <style>
@@ -29,7 +27,7 @@
text-align: center; text-align: center;
width: 90%; width: 90%;
margin: auto; margin: auto;
padding: 10px 0 0 0; padding: 10px 0 0.5rem 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;

View File

@@ -44,6 +44,91 @@
font-display: swap; font-display: swap;
} }
/* 100: Thin */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Thin.woff2') format('woff2');
font-weight: 100;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ThinItalic.woff2') format('woff2');
font-weight: 100;
font-style: italic;
font-display: swap;
}
/* 200: ExtraLight */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraLight.woff2') format('woff2');
font-weight: 200;
font-style: normal;
font-display: swap;
}
/* 300: Light */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Light.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
}
/* 400: Regular / Italic */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
/* 500: Medium */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* 600: SemiBold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
/* 700: Bold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* 800: ExtraBold */
@font-face {
font-family: 'JetBrains Mono';
src: url('/type/jetbrains-mono/JetBrainsMono-ExtraBold.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
:root { :root {
/* Brand Colours */ /* Brand Colours */
--color-brand: #4fd1d1; --color-brand: #4fd1d1;
@@ -58,6 +143,35 @@
--shadow-small: 0 4px 6px var(--color-shadow); --shadow-small: 0 4px 6px var(--color-shadow);
--shadow-up: 0 -4px 12px var(--color-shadow); --shadow-up: 0 -4px 12px var(--color-shadow);
--shadow-right: 4px 0 12px var(--color-shadow); --shadow-right: 4px 0 12px var(--color-shadow);
/* Functional Colours */
--location-yellow: #edff22;
--delay-orange: #ff914d;
--cancel-red: #c60000;
--early-blue: #5ec1ff;
}
/* Pulse Animations */
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes fast-pulse {
0%,
50%,
100% {
opacity: 1;
}
25%,
75% {
opacity: 0;
}
} }
body { body {

View File

@@ -23,27 +23,28 @@ export function formatUkTime(dateStr: string | Date | undefined): string {
* @param 'Schedule Location' object * @param 'Schedule Location' object
* @returns Delay string for departure boards * @returns Delay string for departure boards
*/ */
export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): {val: string, type: string} { export function calculateDelay(loc: ApiTrainsTrainDetails.ServiceLocation): {
const pairs = [ val: string;
{ actual: loc.atd, sched: loc.ptd ?? loc.wtd }, type: string;
{ actual: loc.ata, sched: loc.pta ?? loc.wta }, } {
{ actual: loc.atp, sched: loc.wtp } const pairs = [
]; { actual: loc.atd, sched: loc.ptd ?? loc.wtd },
{ actual: loc.ata, sched: loc.pta ?? loc.wta },
{ actual: loc.atp, sched: loc.wtp }
];
const match = pairs.find(p => p.actual && p.sched); const match = pairs.find((p) => p.actual && p.sched);
if (!match || !match.actual || !match.sched) return {val: '', type: 'none'}; if (!match || !match.actual || !match.sched) return { val: '', type: 'none' };
const diffMinutes = Math.round( const diffMinutes = Math.round((Date.parse(match.actual) - Date.parse(match.sched)) / 60000);
(Date.parse(match.actual) - Date.parse(match.sched)) / 60000
);
if (diffMinutes === 0) return {val: 'RT', type: 'ontime'}; if (diffMinutes === 0) return { val: 'RT', type: 'ontime' };
const absDiff = Math.abs(diffMinutes); const absDiff = Math.abs(diffMinutes);
if (diffMinutes > 0) { if (diffMinutes > 0) {
return { val: `${absDiff}L`, type: 'late' }; return { val: `${absDiff}L`, type: 'late' };
} else { } else {
return { val: `${absDiff}E`, type: 'early' }; return { val: `${absDiff}E`, type: 'early' };
} }
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.