Add NearestStations Card & Location monitor

This commit is contained in:
2026-03-30 23:34:12 +01:00
parent 4a969e626c
commit 777519ff5d
16 changed files with 465 additions and 390 deletions

View File

@@ -27,7 +27,6 @@
return LOCATIONS.data
.filter((r) => tokens.every((t) => r.s.includes(t)))
.sort((a, b) => {
// Priority One - Exact CRS Match
const aExactCrs = a.c?.toLowerCase() === lowerQuery;
const bExactCrs = b.c?.toLowerCase() === lowerQuery;
@@ -36,7 +35,7 @@
// Priority Two - 'Stations' with CRS
if (!!a.c && !b.c) return -1;
if (!a.c & !! b.c) return 1;
if (!a.c & !!b.c) return 1;
// Alphabetical Sort
return a.n.localeCompare(b.n);

View File

@@ -1,53 +1,56 @@
<script lang="ts">
interface Props {
toc: string;
}
interface Props {
toc: string;
}
let {
toc
}: Props = $props();
let { toc }: Props = $props();
let code = $derived(toc.toUpperCase());
let code = $derived(toc.toUpperCase());
</script>
<div class="toc-container {code}">
{code}
{code}
</div>
<style>
.toc-container {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-weight: 800;
background-color: #333;
color: #fff;
}
.toc-container {
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 8px;
font-weight: 800;
background-color: #333;
color: #fff;
}
.GW { /* Great Western Railway */
background: #004225;
color: #E2E2E2;
}
.GW {
/* Great Western Railway */
background: #004225;
color: #e2e2e2;
}
.GR { /* LNER */
background-color: #C00000;
color: #FFFFFF;
}
.GR {
/* LNER */
background-color: #c00000;
color: #ffffff;
}
.VT { /* Avanti West Coast */
background-color: #004354;
color: #FFFFFF;
}
.VT {
/* Avanti West Coast */
background-color: #004354;
color: #ffffff;
}
.SW { /* South Western Railway */
background-color: #2A3389;
color: #FFFFFF;
}
.SW {
/* South Western Railway */
background-color: #2a3389;
color: #ffffff;
}
.XC { /* CrossCountry */
background-color: #660000;
color: #E4D5B1;
}
</style>
.XC {
/* CrossCountry */
background-color: #660000;
color: #e4d5b1;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { fade } from 'svelte/transition';
import { flip } from 'svelte/animate';
import { nearestStationsState } from '$lib/geohash.svelte';
const flipDuration = 300;
</script>
<BaseCard header={'Nearby Stations'}>
<div class="card-content">
{#if nearestStationsState.error && nearestStationsState.list.length === 0}
<p class="msg">{nearestStationsState.error}</p>
{:else if nearestStationsState.loading && nearestStationsState.list.length === 0}
<p class="msg">Locating stations...</p>
{:else}
<div class="stations-flex">
{#each nearestStationsState.list as station (station.c)}
<div
class="btn-container"
animate:flip={{ duration: flipDuration }}
in:fade={{ duration: 200, delay: 100 }}
out:fade={{ duration: 150 }}
>
<Button href={`/board?loc=${station.c}`}
><span class="stn-name">{station.n}</span></Button
>
</div>
{/each}
</div>
{/if}
</div>
</BaseCard>
<style>
.card-content {
text-align: center;
width: 90%;
margin: auto;
padding: 10px 0 10px 0;
}
.stations-flex {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
align-items: flex-start;
}
.btn-container {
display: block;
width: fit-content;
will-change: transform;
}
.msg {
font-size: 1.1rem;
font-weight: 600;
color: var(--color-title);
}
.stn-name {
text-transform: capitalize;
}
</style>

View File

@@ -1,28 +1,34 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (c: string) => void } = $props();
let { onsearch }: { onsearch: (c: string) => void } = $props();
let codeValue = $state('');
let codeValue = $state('');
function resetValues(): void {
codeValue = '';
}
function resetValues(): void {
codeValue = '';
}
</script>
<BaseCard header={'Find by Code'}>
<div class="card-content">
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={"Code"} uppercase={true} type={'number'} max={9999} bind:value={codeValue} />
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox
placeholder={'Code'}
uppercase={true}
type={'number'}
max={9999}
bind:value={codeValue}
/>
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(codeValue.toString())}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</BaseCard>
@@ -34,21 +40,21 @@ function resetValues(): void {
padding: 10px 0 10px 0;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-item-wrapper {
width: 30%;
}
.textbox-item-wrapper {
width: 30%;
}
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>

View File

@@ -1,34 +1,33 @@
<script lang="ts">
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
import BaseCard from '$lib/components/ui/cards/BaseCard.svelte';
import Textbox from '$lib/components/ui/Textbox.svelte';
import Button from '$lib/components/ui/Button.svelte';
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
let { onsearch }: { onsearch: (s: string, e: string) => void } = $props();
let startValue = $state('');
let endValue = $state('');
let startValue = $state('');
let endValue = $state('');
function resetValues(): void {
startValue = '';
endValue = '';
}
function resetValues(): void {
startValue = '';
endValue = '';
}
</script>
<BaseCard header={'Find by Start/End CRS'}>
<div class="card-content">
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={"Start"} uppercase={true} maxLength={3} bind:value={startValue} />
</div>
<div class="textbox-item-wrapper">
<Textbox placeholder={"End"} uppercase={true} maxLength={3} bind:value={endValue} />
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
<div class="textbox-container">
<div class="textbox-item-wrapper">
<Textbox placeholder={'Start'} uppercase={true} maxLength={3} bind:value={startValue} />
</div>
<div class="textbox-item-wrapper">
<Textbox placeholder={'End'} uppercase={true} maxLength={3} bind:value={endValue} />
</div>
</div>
<div class="button-wrapper">
<Button onclick={() => onsearch(startValue, endValue)}>Search</Button>
<Button onclick={resetValues}>Reset</Button>
</div>
</div>
</BaseCard>
@@ -40,21 +39,21 @@ function resetValues(): void {
padding: 10px 0 10px 0;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-container {
display: flex;
width: 100%;
justify-content: center;
gap: 4rem;
}
.textbox-item-wrapper {
width: 30%;
}
.textbox-item-wrapper {
width: 30%;
}
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>
.button-wrapper {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 15px;
}
</style>

90
src/lib/geohash.svelte.ts Normal file
View File

@@ -0,0 +1,90 @@
import { OwlClient, ValidationError, ApiError } from './owlClient';
import type { ApiStationsNearestStations } from '@owlboard/owlboard-ts';
class NearestStationsState {
list = $state<ApiStationsNearestStations.StationsNearestStations[]>([]);
currentHash = $state('');
loading = $state(true);
error = $state<string | null>(null);
private geoConfig: PositionOptions = {
enableHighAccuracy: false,
timeout: 30000,
maximumAge: 120000
};
constructor() {
if (typeof window !== 'undefined' && 'geolocation' in navigator) {
this.jumpstart();
this.initWatcher();
}
}
private jumpstart() {
navigator.geolocation.getCurrentPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
);
}
private initWatcher() {
navigator.geolocation.watchPosition(
(pos) => this.handleUpdate(pos.coords.latitude, pos.coords.longitude),
(err) => this.handleError(err),
this.geoConfig
);
}
private async handleUpdate(lat: number, lon: number) {
const newHash = OwlClient.stationData.generateGeohash(lat, lon);
if (newHash !== this.currentHash) {
this.loading = true;
try {
const result = await OwlClient.stationData.getNearestStations(newHash);
this.list = result.data;
this.error = null;
this.currentHash = newHash;
} catch (e) {
this.handleApiError(e);
} finally {
this.loading = false;
}
}
}
private handleError(err: GeolocationPositionError) {
if (err.code === 1) {
this.error = 'Location access denied by device';
} else {
this.error = 'Waiting for GPS signal...';
}
}
private handleApiError(e: unknown) {
if (e instanceof ValidationError) {
this.error = `Request Error: ${e.reason} (Field: ${e.field})`;
} else if (e instanceof ApiError) {
switch (e.status) {
case 404:
this.error = 'No stations found nearby';
break;
case 429:
this.error = 'Too many requests, will retry';
break;
case 500:
this.error = 'Server Error, will retry';
break;
default:
this.error = `Service error: ${e.code}`;
}
} else {
this.error = 'Connection lost, waiting for signal';
}
console.error('OwlBoard API Error:', e);
}
}
export const nearestStationsState = new NearestStationsState();

View File

@@ -1,5 +1,5 @@
import { OwlClient } from "./owlClient";
import type { ApiLocationFilter } from '@owlboard/owlboard-ts'
import { OwlClient } from './owlClient';
import type { ApiLocationFilter } from '@owlboard/owlboard-ts';
class LocationStore {
data = $state<ApiLocationFilter.LocationFilterObject[]>([]);
@@ -9,7 +9,7 @@ class LocationStore {
if (this.loaded) return;
try {
const fetch = await OwlClient.locationFilter.getLocationFilterData()
const fetch = await OwlClient.locationFilter.getLocationFilterData();
this.data = fetch.data;
this.loaded = true;
} catch (err) {

View File

@@ -1,21 +1,21 @@
import { OwlBoardClient, ValidationError, ApiError } from "@owlboard/owlboard-ts";
import { browser, dev } from "$app/environment";
import { OwlBoardClient, ValidationError, ApiError } from '@owlboard/owlboard-ts';
import { browser, dev } from '$app/environment';
// Import the runes containing the API Key config Here...
const baseUrl: string = browser ? window.location.origin : '';
const getBaseUrl = () => {
if (!browser) return '';
if (!browser) return '';
if (dev) return 'https://test.owlboard.info';
if (dev) return 'https://test.owlboard.info';
return window.location.origin;
}
return window.location.origin;
};
export const OwlClient = new OwlBoardClient(
getBaseUrl(),
// API Key Here when ready!!!
)
getBaseUrl()
// API Key Here when ready!!!
);
export { ValidationError, ApiError };
export { ValidationError, ApiError };

View File

@@ -4,6 +4,7 @@
import { onMount } from 'svelte';
import { LOCATIONS } from '$lib/locations-object.svelte';
import { nearestStationsState } from '$lib/geohash.svelte';
import '$lib/global.css';

View File

@@ -1,9 +1,11 @@
<script lang="ts">
import LocationBoardCard from '$lib/components/ui/cards/LocationBoardCard.svelte';
import NearbyStationsCard from '$lib/components/ui/cards/NearbyStationsCard.svelte';
</script>
<div class="card-container">
<LocationBoardCard />
<NearbyStationsCard />
</div>
<style>

View File

@@ -30,9 +30,9 @@
</p>
<p class="amble">
Why OwlBoard? The name was chosen as an evolution of its predecessor, 'Athena'; owls are
associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links to Bath, where the
app has been built and is run, relating to the 'Minerva Owl' sculpture trail in the city, with
many of the sculptures still in the area.
associated with the equivalent Roman Goddess - Minerva - as well as with wisdom. This also links
to Bath, where the app has been built and is run, relating to the 'Minerva Owl' sculpture trail
in the city, with many of the sculptures still in the area.
</p>
<p class="opensource">
Some components that combine to form OwlBoard are open-source, see the <a

View File

@@ -1,98 +1,102 @@
<script lang="ts">
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
import PisStartEndCard from '$lib/components/ui/cards/pis/PisStartEndCard.svelte';
import PisCode from '$lib/components/ui/cards/pis/PisCode.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { ApiPisObject } from '@owlboard/owlboard-ts';
import { OwlClient, ApiError, ValidationError } from '$lib/owlClient';
import TocStyle from '$lib/components/ui/TocStyle.svelte';
let results = $state<ApiPisObject.PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false);
let errorState = $state<{status: number, message: string} | null>(null);
let results = $state<ApiPisObject.PisObjects[]>([]);
let resultsLoaded = $state<boolean>(false);
let errorState = $state<{ status: number; message: string } | null>(null);
async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`);
errorState = null;
async function handleStartEndSearch(start: string, end: string): Promise<void> {
console.log(`PIS Search: ${start}-${end}`);
errorState = null;
try {
const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || [];
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
console.log(e)
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
} finally {
resultsLoaded = true;
}
}
try {
const response = await OwlClient.pis.getByStartEndCrs(start, end);
results = response.data || [];
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
console.log(e);
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
} finally {
resultsLoaded = true;
}
}
async function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`);
errorState = null;
try {
const response = await OwlClient.pis.getByCode(code);
results = response.data || []
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
console.log(e)
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
} finally {
resultsLoaded = true;
}
}
async function handleCodeSearch(code: string) {
console.log(`PIS Search: ${code}`);
errorState = null;
try {
const response = await OwlClient.pis.getByCode(code);
results = response.data || [];
} catch (e) {
if (e instanceof ValidationError) {
errorState = { status: 400, message: e.message };
} else if (e instanceof ApiError) {
console.log(e);
errorState = { status: 20, message: e.message };
} else {
errorState = { status: 0, message: `Unknown Error: ${e.message}` };
}
} finally {
resultsLoaded = true;
}
}
function clearResults() {
console.log('Clearing Results');
resultsLoaded = false;
results = [];
}
function clearResults() {
console.log('Clearing Results');
resultsLoaded = false;
results = [];
}
</script>
{#if !resultsLoaded}
<div class="card-container">
<PisStartEndCard onsearch={handleStartEndSearch} />
<PisCode onsearch={handleCodeSearch} />
</div>
<div class="card-container">
<PisStartEndCard onsearch={handleStartEndSearch} />
<PisCode onsearch={handleCodeSearch} />
</div>
{:else}
<div class="result-container">
{#if errorState}
<span class="errCode">Error: {errorState.status}</span>
<span class="errMsg">{errorState.message}</span>
{:else}
{#if results.length}
<h2 class="result-title">{results.length} Result{#if results.length > 1}s{/if} found</h2>
<table class="result-table">
<thead>
<tr>
<th style="width:16%">TOC</th>
<th style="width:14%">Code</th>
<th style="width:70%">Locations</th>
</tr></thead>
{#each results as result}
<tbody><tr>
<td><TocStyle toc={result.toc || ""} /></td>
<td>{result.code}</td>
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
</tr></tbody>
{/each}
</table>
{:else}
<p class="no-results">No matching results</p>
{/if}
{/if}
<div class="reset-button-container">
<Button onclick={clearResults}>Reset</Button>
</div> </div>
<div class="result-container">
{#if errorState}
<span class="errCode">Error: {errorState.status}</span>
<span class="errMsg">{errorState.message}</span>
{:else if results.length}
<h2 class="result-title">
{results.length} Result{#if results.length > 1}s{/if} found
</h2>
<table class="result-table">
<thead>
<tr>
<th style="width:16%">TOC</th>
<th style="width:14%">Code</th>
<th style="width:70%">Locations</th>
</tr></thead
>
{#each results as result}
<tbody
><tr>
<td><TocStyle toc={result.toc || ''} /></td>
<td>{result.code}</td>
<td class="locations-row">{result.crsStops?.join(' ') || ''}</td>
</tr></tbody
>
{/each}
</table>
{:else}
<p class="no-results">No matching results</p>
{/if}
<div class="reset-button-container">
<Button onclick={clearResults}>Reset</Button>
</div>
</div>
{/if}
<style>
@@ -105,54 +109,54 @@
padding: 20px 10px;
}
.result-container {
font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem;
font-weight: 600;
display: flex;
align-items: center;
flex-direction: column;
gap: 5px;
justify-content: center;
background: var(--color-accent);
border-radius: 15px;
padding: 20px 0 20px 0;
margin: auto;
margin-top: 25px;
margin-bottom: 25px;
width: 90%;
max-width: 500px;
.result-container {
font-family: 'URW Gothic', sans-serif;
font-size: 1.2rem;
font-weight: 600;
display: flex;
align-items: center;
flex-direction: column;
gap: 5px;
justify-content: center;
background: var(--color-accent);
border-radius: 15px;
padding: 20px 0 20px 0;
margin: auto;
margin-top: 25px;
margin-bottom: 25px;
width: 90%;
max-width: 500px;
box-shadow: var(--shadow-std);
}
}
.result-title {
color: var(--color-brand);
}
.result-title {
color: var(--color-brand);
}
.result-table {
width: 90%;
max-width: 350px;
margin: auto;
text-align: center;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 20px;
font-weight: 400;
}
.result-table {
width: 90%;
max-width: 350px;
margin: auto;
text-align: center;
table-layout: fixed;
border-collapse: separate;
border-spacing: 0 20px;
font-weight: 400;
}
.locations-row {
font-family:'Courier New', Courier, monospace;
text-align: left;
padding-left: 20px;
}
.locations-row {
font-family: 'Courier New', Courier, monospace;
text-align: left;
padding-left: 20px;
}
.errCode {
color: rgb(255, 54, 54);
font-weight: 600;
font-size: 2rem;
}
.errCode {
color: rgb(255, 54, 54);
font-weight: 600;
font-size: 2rem;
}
.reset-button-container {
padding: 20px 0 3px 0;
}
</style>
.reset-button-container {
padding: 20px 0 3px 0;
}
</style>

View File

@@ -2,4 +2,4 @@ export const load = () => {
return {
title: 'PIS Codes'
};
};
};