Compare commits

...

3 Commits

Author SHA1 Message Date
Vegard Berg 8b9ba7f81b preliminary line route 2023-10-12 00:21:17 +02:00
Vegard Berg 52c38966c4 Add delay component 2023-10-11 19:15:04 +02:00
Vegard Berg bd2be948eb Remove dark theme 2023-10-11 19:14:52 +02:00
13 changed files with 352 additions and 50 deletions

View File

@ -11,6 +11,7 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.4.3", "@sveltejs/vite-plugin-svelte": "^2.4.3",
"@sveltejs/vite-plugin-svelte-inspector": "^1.0.4",
"@tsconfig/svelte": "^5.0.0", "@tsconfig/svelte": "^5.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1", "@vitejs/plugin-basic-ssl": "^1.0.1",
"svelte": "^4.1.2", "svelte": "^4.1.2",
@ -21,6 +22,7 @@
}, },
"dependencies": { "dependencies": {
"@urql/svelte": "^4.0.4", "@urql/svelte": "^4.0.4",
"geojson": "^0.5.0",
"graphql": "^16.8.1", "graphql": "^16.8.1",
"graphql-ws": "^5.14.1", "graphql-ws": "^5.14.1",
"subscriptions-transport-ws": "^0.11.0", "subscriptions-transport-ws": "^0.11.0",

View File

@ -3,14 +3,13 @@
import { Client, setContextClient, cacheExchange, fetchExchange, subscriptionExchange } from '@urql/svelte'; import { Client, setContextClient, cacheExchange, fetchExchange, subscriptionExchange } from '@urql/svelte';
import { createClient as createWsClient } from 'graphql-ws'; import { createClient as createWsClient } from 'graphql-ws';
import { SubscriptionClient } from 'subscriptions-transport-ws'; import { SubscriptionClient } from 'subscriptions-transport-ws';
import { createJourneyPlannerClientContext, createVehiclesClientContext } from './lib/entur';
let subscriptionClient = new SubscriptionClient('wss://api.entur.io/realtime/v1/vehicles/subscriptions', { reconnect: true}) createVehiclesClientContext();
createJourneyPlannerClientContext();
/* /*
let wsClient = createWsClient({ let subscriptionClient = new SubscriptionClient('wss://api.entur.io/realtime/v1/vehicles/subscriptions', { reconnect: true})
url: 'wss://api.entur.io/realtime/v1/vehicles/subscriptions'
})
*/
let client = new Client({ let client = new Client({
url: 'https://api.entur.io/realtime/v1/vehicles/graphql', url: 'https://api.entur.io/realtime/v1/vehicles/graphql',
@ -20,21 +19,13 @@
subscriptionExchange({ subscriptionExchange({
forwardSubscription(request) { forwardSubscription(request) {
return subscriptionClient.request(request); return subscriptionClient.request(request);
/*
const input = { ...request, query: request.query || "" };
return {
subscribe(sink) {
const unsubscribe = wsClient.subscribe(input, sink);
return { unsubscribe };
}
}
*/
} }
}) })
], ],
}) })
setContextClient(client) setContextClient(client)
*/
</script> </script>
<main> <main>

View File

@ -3,10 +3,6 @@
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;

73
src/lib/LineMarker.svelte Normal file
View File

@ -0,0 +1,73 @@
<script lang="ts">
import { GeoJSON, LineLayer, MarkerLayer } from "svelte-maplibre";
import { getJourneyPlannerClientContext, quaysToGeoJSON } from "./entur";
import { gql, queryStore } from "@urql/svelte";
import type { GeoJSON as IGeoJSON } from "geojson";
export let lineRef: string | null = null;
console.log(lineRef + "hi")
$: queryResult = queryStore({
client: getJourneyPlannerClientContext(),
query: gql`
query ($line: ID!) {
line(id: $line) {
publicCode
name
journeyPatterns {
directionType
quays {
longitude
latitude
name
publicCode
}
}
}
}
`,
variables: { line: lineRef }
})
let data: IGeoJSON;
$: if ($queryResult.data) {
data = quaysToGeoJSON(...$queryResult.data.line.journeyPatterns.map((x: any) => x.quays)) as IGeoJSON
}
</script>
<style>
h1 {
z-index: 1000;
position: relative;
}
code {
z-index: 100;
position: absolute;
bottom: 0.5em;
left: 5em;
right: 5em;
max-height: 10em;
overflow-y: scroll;
background-color: white;
}
</style>
{#if lineRef != null}
<GeoJSON data={data}>
<LineLayer
layout={{ 'line-cap': 'round', 'line-join': 'round' }}
paint={{
'line-width': 5,
'line-dasharray': [5, 2],
'line-color': '#008800',
'line-opacity': 0.8,
}}
/>
<MarkerLayer applyToClusters={false} let:feature>
<span>{feature.properties?.name ?? "N/A"}</span>
</MarkerLayer>
</GeoJSON>
{/if}

View File

@ -1,18 +1,34 @@
<script lang="ts"> <script lang="ts">
import { Control, ControlButton, ControlGroup, MapLibre, Marker, NavigationControl, Popup, mapContext } from 'svelte-maplibre'; import { Control, ControlButton, ControlGroup, LineLayer, MapLibre, Marker, NavigationControl, Popup, mapContext, GeoJSON, MarkerLayer } from 'svelte-maplibre';
import VehicleMarker from "./VehicleMarker.svelte" import VehicleMarker from "./VehicleMarker.svelte"
import {writable} from 'svelte/store'; import {writable, type Writable} from 'svelte/store';
import { queryStore, gql, getContextClient, subscriptionStore } from '@urql/svelte'; import { queryStore, gql, getContextClient, subscriptionStore } from '@urql/svelte';
import { getJourneyPlannerClientContext, getVehiclesClientContext, quaysToGeoJSON } from './entur';
import { buses, selectedLine } from './store';
import LineMarker from './LineMarker.svelte';
import type { GeoJSON as IGeoJSON } from 'geojson';
interface Vehicle {
vehicleId: string;
location: {longitude: number, latitude: number};
bearing: number;
line: {lineRef: string, lineName: string, publicCode: string};
originName: string;
destinationName: string;
occupancy: string;
delay: number;
vehicleStatus: string;
}
$: bearing = 0.0; $: bearing = 0.0;
setInterval(() => bearing = 0, 100) setInterval(() => bearing = 0, 100)
$: iconSize = 30; $: iconSize = 35;
let dataStore = writable({}); let dataStore: Writable<Record<string, Vehicle>> = writable({});
let vehiclesInit = queryStore({ let vehiclesInit = queryStore({
client: getContextClient(), client: getVehiclesClientContext(),
query: gql` query: gql`
query { query {
vehicles(codespaceId:"SKY") { vehicles(codespaceId:"SKY") {
@ -41,7 +57,7 @@ import { queryStore, gql, getContextClient, subscriptionStore } from '@urql/svel
vehiclesInit.subscribe((v) => { vehiclesInit.subscribe((v) => {
if (v.data) { if (v.data) {
for (const veh of v.data.vehicles.filter(x => ["AT_ORIGIN", "IN_PROGRESS", "OFF_ROUTE"].includes(x.vehicleStatus))) { for (const veh of v.data.vehicles.filter((x: Vehicle) => ["AT_ORIGIN", "IN_PROGRESS", "OFF_ROUTE"].includes(x.vehicleStatus))) {
dataStore.update((d: any) => { dataStore.update((d: any) => {
d[veh.vehicleId] = veh; d[veh.vehicleId] = veh;
return d; return d;
@ -51,10 +67,10 @@ vehiclesInit.subscribe((v) => {
}) })
const vehicles = subscriptionStore({ const vehicles = subscriptionStore({
client: getContextClient(), client: getVehiclesClientContext(),
query: gql` query: gql`
subscription { subscription {
vehicles(codespaceId:"SKY") { vehicles(codespaceId: "SKY") {
vehicleId vehicleId
line { line {
lineRef lineRef
@ -85,10 +101,44 @@ vehiclesInit.subscribe((v) => {
} }
}); });
$: queryResult = queryStore({
client: getJourneyPlannerClientContext(),
query: gql`
query ($line: ID!) {
line(id: $line) {
publicCode
name
journeyPatterns {
directionType
quays {
longitude
latitude
name
publicCode
}
}
}
}
`,
variables: { line: $selectedLine }
})
let data: IGeoJSON;
$: if ($queryResult.data) {
data = quaysToGeoJSON(...$queryResult.data.line.journeyPatterns.map((x: any) => x.quays)) as IGeoJSON
}
</script> </script>
<style> <style>
div.quay {
z-index: -1;
padding: 0.1em;
background-color: #baddad;
border: 1px dotted #333;
border-radius: 5px;
}
</style> </style>
<div> <div>
@ -112,14 +162,34 @@ vehiclesInit.subscribe((v) => {
<ControlButton on:click={() => iconSize = 20}>S</ControlButton> <ControlButton on:click={() => iconSize = 20}>S</ControlButton>
</ControlGroup> </ControlGroup>
</Control> </Control>
<Control position="bottom-right">
<ControlGroup>
<ControlButton>{$buses}</ControlButton>
</ControlGroup>
</Control>
{#if $selectedLine != null}
<p id="foo">
<GeoJSON {data}>
<LineLayer
paint={{'line-width': 5, 'line-color': 'orange'}}
beforeLayerType={(l) => {console.log(l.type); return false}}
/>
<MarkerLayer let:feature>
<div class="quay">{feature.properties?.name ?? ""}</div>
</MarkerLayer>
</GeoJSON>
</p>
{/if}
{#if $vehicles} {#if $vehicles}
{#each Object.entries($dataStore) as [id, vehicle]} {#each Object.entries($dataStore) as [id, vehicle]}
<VehicleMarker <VehicleMarker
lngLat={[vehicle.location.longitude, vehicle.location.latitude]} lngLat={[vehicle.location.longitude, vehicle.location.latitude]}
lineRef={vehicle.line.lineRef}
lineNumber={vehicle.line.publicCode} lineNumber={vehicle.line.publicCode}
lineName={vehicle.line.lineName} lineName={vehicle.line.lineName}
bearing={vehicle.bearing + map?.getBearing() % 360} bearing={vehicle.bearing}
origin={vehicle.originName} origin={vehicle.originName}
destination={vehicle.destinationName} destination={vehicle.destinationName}
iconSize={iconSize} iconSize={iconSize}

View File

@ -1,7 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Marker, Popup } from 'svelte-maplibre'; import { Marker, Popup } from 'svelte-maplibre';
import Delay from './micro/Delay.svelte';
import Bus from './micro/Bus.svelte';
import { buses, selectedLine } from './store';
import { onDestroy } from 'svelte';
export let lngLat: [number, number]; export let lngLat: [number, number];
export let lineRef: string | null = null;
export let lineNumber: string; export let lineNumber: string;
export let lineName: string = "N/A"; export let lineName: string = "N/A";
export let bearing: number = 0.0; export let bearing: number = 0.0;
@ -11,34 +16,43 @@ export let iconSize: number = 30;
export let delay: number = 0; export let delay: number = 0;
export let occupancy: string = "Unknown"; export let occupancy: string = "Unknown";
console.log("lineNumber: %s", lineNumber) $buses += 1
onDestroy(() => {
$buses -= 1
})
const clickHandler = () => {
if ($selectedLine == lineRef)
selectedLine.set(null)
else
selectedLine.set(lineRef)
console.log("click\t%s", $selectedLine)
}
</script> </script>
<style> <style>
svg text {
font-weight: 600; div.popover-content {
font-size: 1.25rem;
color: black; color: black;
background-color: white;; background-color: white;
} }
</style> </style>
<Marker {lngLat}> <Marker {lngLat} on:click={clickHandler}>
<svg width={iconSize} height={iconSize} viewBox="0 0 50 50"> <Bus {iconSize} {bearing} face={lineNumber} />
<g transform="rotate({bearing}, 25, 25)">
<circle cx="25" cy="25" r="24" stroke="black" stroke-width="3" fill="red" />
<circle cx="25" cy="5" r="5" stroke="black" stroke-width="1.5" fill="white" />
</g>
<text x="25" y="32.5" text-anchor="middle">{lineNumber ?? "🚍"}</text>
</svg>
<Popup openOn="hover" offset={[0, -10]}> <Popup openOn="hover" offset={[0, -10]}>
<div class="popover-content">
<h4>{lineNumber}: {lineName}</h4> <h4>{lineNumber}: {lineName}</h4>
<p>Going from <i>{origin}</i> to <i>{destination}</i></p> <p>
<ul> Going from <i>{origin}</i> to <i>{destination}</i>
<li>Occupancy: {occupancy}</li> <br />
<li>Delay: {delay}</li> <Delay {delay} />
</ul> <br />
{occupancy}
</p>
</div>
</Popup> </Popup>
</Marker> </Marker>

83
src/lib/entur.ts Normal file
View File

@ -0,0 +1,83 @@
import { Client, cacheExchange, fetchExchange, subscriptionExchange } from "@urql/svelte";
import { SubscriptionClient } from "subscriptions-transport-ws";
import { setContext, getContext } from "svelte";
export function createVehiclesClientContext() {
let subscriptionClient = new SubscriptionClient('wss://api.entur.io/realtime/v1/vehicles/subscriptions', { reconnect: true})
let client = new Client({
url: 'https://api.entur.io/realtime/v1/vehicles/graphql',
exchanges: [
fetchExchange,
cacheExchange,
subscriptionExchange({
forwardSubscription(request) {
return subscriptionClient.request(request);
}
})
],
})
setContext("entur-graphql-vehiclepositions-client", client)
}
export function getVehiclesClientContext(): Client {
return getContext("entur-graphql-vehiclepositions-client")
}
export function createJourneyPlannerClientContext() {
let client = new Client({
url: 'https://api.entur.io/journey-planner/v3/graphql',
exchanges: [
fetchExchange,
cacheExchange,
]
})
setContext("entur-graphql-journeyplannet-client", client)
}
export function getJourneyPlannerClientContext(): Client {
return getContext("entur-graphql-journeyplannet-client")
}
export interface Quay {
longitude: number;
latitude: number;
name: string;
publicCode: string;
}
export function quaysToGeoJSON(...quayCollections: Array<Array<Quay>>) {
const multilineString = {
type: "Feature",
properties: {},
geometry: {
type: "MultiLineString",
coordinates: quayCollections.map((quays => quays.map(quay => [quay.longitude, quay.latitude]))),
},
}
const features = quayCollections.map(quays =>
quays.map(quay => {
return {
type: "Feature",
properties: {
name: quay.name + (quay.publicCode ? (" " + quay.publicCode) : "")
},
geometry: {
type: "Point",
coordinates: [quay.longitude, quay.latitude]
}
}
}))
return {
type: "FeatureCollection",
features: [
multilineString,
...features.flat(1)
]
}
}

28
src/lib/micro/Bus.svelte Normal file
View File

@ -0,0 +1,28 @@
<script lang="ts">
export let iconSize = 35;
export let bearing = 0;
export let face: string = "";
</script>
<style>
svg {
z-index: -1000;
}
text {
font-weight: 600;
font-size: 1.25rem;
color: darkgray;
background-color: white;
}
</style>
<svg width={iconSize * 1.2} height={iconSize * 1.2} viewBox="-10 -10 70 70">
<g transform="rotate({bearing}, 25, 25)">
<!--
<circle cx="25" cy="25" r="24" stroke="black" stroke-width="3" fill="red" />
<circle cx="25" cy="5" r="5" stroke="black" stroke-width="1.5" fill="white" />
-->
<path d="M 25 0 l -25 10 v 40 l 25 -10 l 25 10 v -40 l -25 -10" fill="green" stroke="black" stroke-width="3" />
</g>
<text x="25" y="32.5" text-anchor="middle" fill="white" stroke="black" stroke-width="0.5">{face ?? "🚍"}</text>
</svg>

View File

@ -0,0 +1,22 @@
<script lang="ts">
/** The delay in seconds.*/
export let delay: number;
let delay_min = Math.round(delay / 60);
let delay_abs = Math.abs(delay_min);
</script>
<div>
The bus is
{#if delay_min == 1}
<span>one minute delayed.</span>
{:else if delay_min > 1}
<span>{delay_abs} minutes delayed.</span>
{:else if delay_min == -1}
<span>one minute ahead of schedule.</span>
{:else if delay_min < -1}
<span>{delay_abs} minutes ahead of schedule.</span>
{:else}
<span>on time.</span>
{/if}
</div>

5
src/lib/store.ts Normal file
View File

@ -0,0 +1,5 @@
import { writable, type Writable } from "svelte/store";
export const buses = writable(0);
export const selectedLine: Writable<string|null> = writable(null);

View File

@ -4,4 +4,11 @@ export default {
// Consult https://svelte.dev/docs#compile-time-svelte-preprocess // Consult https://svelte.dev/docs#compile-time-svelte-preprocess
// for more information about preprocessors // for more information about preprocessors
preprocess: vitePreprocess(), preprocess: vitePreprocess(),
vitePlugin: {
inspector: {
toggleKeyCombo: 'meta-shift',
showToggleButton: 'always',
toggleButtonPos: 'bottom-right'
}
}
} }

View File

@ -1,12 +1,18 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import { svelte } from '@sveltejs/vite-plugin-svelte' import { svelte } from '@sveltejs/vite-plugin-svelte'
import basicSsl from '@vitejs/plugin-basic-ssl' import basicSsl from '@vitejs/plugin-basic-ssl'
import { svelteInspector } from '@sveltejs/vite-plugin-svelte-inspector'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
svelte(), svelte(),
//basicSsl(), //basicSsl(),
/*svelteInspector({
showToggleButton: 'always',
toggleButtonPos: 'top-right',
openKey: 'meta-shift'
})*/
], ],
base: "", base: "",
}) })

View File

@ -232,7 +232,7 @@
"@sveltejs/vite-plugin-svelte-inspector@^1.0.4": "@sveltejs/vite-plugin-svelte-inspector@^1.0.4":
version "1.0.4" version "1.0.4"
resolved "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz" resolved "https://registry.yarnpkg.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz#c99fcb73aaa845a3e2c0563409aeb3ee0b863add"
integrity sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ== integrity sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==
dependencies: dependencies:
debug "^4.3.4" debug "^4.3.4"
@ -594,6 +594,11 @@ geojson-vt@^3.2.1:
resolved "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz" resolved "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz"
integrity sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg== integrity sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==
geojson@^0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/geojson/-/geojson-0.5.0.tgz#3cd6c96399be65b56ee55596116fe9191ce701c0"
integrity sha512-/Bx5lEn+qRF4TfQ5aLu6NH+UKtvIv7Lhc487y/c8BdludrCTpiWf9wyI0RTyqg49MFefIAvFDuEi5Dfd/zgNxQ==
get-stream@^6.0.1: get-stream@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz" resolved "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz"