Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion lang/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1429,5 +1429,15 @@
"validation.attributes.price": "Preis",
"validation.attributes.role": "Rolle",
"validation.attributes.subject": "Thema",
"validation.attributes.terms": "Bedingungen"
"validation.attributes.terms": "Bedingungen",
"filter.title": "Filter",
"filter.reset": "Zurücksetzen",
"filter.period": "Zeitraum",
"filter.travel-type": "Verkehrsmittel",
"filter.travel-purpose": "Reisezweck",
"route-map.title": "Streckenkarte",
"route-map.description": "Alle Strecken, die du mindestens einmal befahren hast.",
"route-map.approximated.hint": "Abschnitte ohne hinterlegten Streckenverlauf werden als Luftlinie zwischen beiden Stationen gezeichnet.",
"route-map.include-approximated": "Geschätzte Abschnitte anzeigen",
"route-map.empty": "Zu diesen Filtern gibt es keine Fahrten."
}
12 changes: 11 additions & 1 deletion lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1426,5 +1426,15 @@
"stats.to": "To",
"stats.today": "today",
"beta.banner.text": "You are viewing a new design that is still under development. You may encounter occasional errors. You can disable experimental features in your",
"beta.banner.settings": "settings."
"beta.banner.settings": "settings.",
"filter.title": "Filter",
"filter.reset": "Reset",
"filter.period": "Period",
"filter.travel-type": "Mode of transport",
"filter.travel-purpose": "Purpose of travel",
"route-map.title": "Route map",
"route-map.description": "Every stretch you have travelled at least once.",
"route-map.approximated.hint": "Stretches without a stored route are drawn as a straight line between both stations.",
"route-map.include-approximated": "Show estimated stretches",
"route-map.empty": "No journeys match these filters."
}
12 changes: 11 additions & 1 deletion lang/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -1428,5 +1428,15 @@
"your-access-token": "Tes jetons d'accès",
"your-access-token-description": "Tu peux générer un jeton d'accès pour accéder à ton propre compte.",
"your-access-token.ask": "Chez Träwelling, nous ne te demanderons jamais ton jeton d'accès. Si on te le demande, il s'agit probablement d'une usurpation d'identité.",
"your-apps": "Tes applications"
"your-apps": "Tes applications",
"filter.title": "Filtres",
"filter.reset": "Réinitialiser",
"filter.period": "Période",
"filter.travel-type": "Moyen de transport",
"filter.travel-purpose": "Motif du voyage",
"route-map.title": "Carte du réseau",
"route-map.description": "Tous les tronçons que tu as parcourus au moins une fois.",
"route-map.approximated.hint": "Les tronçons sans itinéraire enregistré sont tracés en ligne droite entre les deux gares.",
"route-map.include-approximated": "Afficher les tronçons estimés",
"route-map.empty": "Aucun trajet ne correspond à ces filtres."
}
32 changes: 32 additions & 0 deletions resources/tailwind-app/helpers/polyline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Decodes a Google Encoded Polyline into GeoJSON coordinate pairs.
*/
export function decodePolyline(encoded: string, precision = 5): [number, number][] {
const factor = 10 ** precision;
const coordinates: [number, number][] = [];
let index = 0;
let latitude = 0;
let longitude = 0;

const nextDelta = (): number => {
let result = 0;
let shift = 0;
let byte: number;

do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);

return result & 1 ? ~(result >> 1) : result >> 1;
};

while (index < encoded.length) {
latitude += nextDelta();
longitude += nextDelta();
coordinates.push([longitude / factor, latitude / factor]);
}

return coordinates;
}
2 changes: 2 additions & 0 deletions resources/tailwind-app/layouts/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ import {
ShieldCogCorner,
Ticket,
User,
Waypoints,
X,
} from '@lucide/vue';
import { trans } from 'laravel-vue-i18n';
Expand Down Expand Up @@ -338,6 +339,7 @@ const userLinks = computed<
legacy: false,
},
{ name: 'trip.list.title', icon: Route, route: { name: 'trip-list' } },
{ name: 'route-map.title', icon: Waypoints, route: { name: 'route-map' } },
{ name: 'menu.export', icon: Save, route: { name: 'export' } },
{ name: 'menu.settings', icon: Settings, route: { name: 'settings-profile' } },
{ name: 'tickets.title', icon: Ticket, route: { name: 'tickets' }, condition: user.isClosedBeta },
Expand Down
109 changes: 109 additions & 0 deletions resources/tailwind-app/pages/RouteMap/RouteMap.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<script setup lang="ts">
import { TriangleAlert, Waypoints } from '@lucide/vue';
import { trans } from 'laravel-vue-i18n';
import { onUnmounted, ref, watch } from 'vue';
import { Api, RouteMapEntryResource } from '../../../types/Api.gen';
import Loading from '../../components/Loading.vue';
import AppLayout from '../../layouts/AppLayout.vue';
import { defaultFilterState, RouteMapFilterState, toQuery } from './filters';
import RouteMapCanvas from './partials/RouteMapCanvas.vue';
import RouteMapFilters from './partials/RouteMapFilters.vue';

const DEBOUNCE_MS = 400;

const api = new Api({ baseUrl: window.location.origin + '/api/v1' });

const filters = ref<RouteMapFilterState>(defaultFilterState());
const entries = ref<RouteMapEntryResource[]>([]);
const loading = ref(true);
const failed = ref(false);

let latestRequest = 0;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;

function fetchRouteMap(): void {
const requestId = ++latestRequest;
loading.value = true;
failed.value = false;

api.routeMap
.getRouteMap(toQuery(filters.value))
.then((response) => {
if (requestId !== latestRequest) {
return;
}
entries.value = response.data.data ?? [];
loading.value = false;
})
.catch(() => {
if (requestId !== latestRequest) {
return;
}
entries.value = [];
failed.value = true;
loading.value = false;
});
}

watch(
filters,
() => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(fetchRouteMap, DEBOUNCE_MS);
},
{ deep: true },
);

onUnmounted(() => {
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
}
});

fetchRouteMap();
</script>

<template>
<AppLayout>
<div class="flex items-center gap-2 mb-1">
<h1 class="text-2xl font-bold flex items-center gap-2">
<Waypoints class="size-6" />
{{ trans('route-map.title') }}
</h1>
<Loading v-if="loading" />
</div>
<p class="text-sm text-base-content/60 mb-6">{{ trans('route-map.description') }}</p>

<div class="grid grid-cols-1 lg:grid-cols-4 gap-4">
<div class="lg:col-span-1">
<RouteMapFilters v-model="filters" />
</div>

<div class="lg:col-span-3 flex flex-col gap-4">
<div v-if="failed" class="alert alert-error">
<TriangleAlert class="size-5" />
<span>{{ trans('error.500') }}</span>
</div>

<div class="relative rounded-box overflow-hidden h-[55vh] lg:h-[70vh] isolate bg-base-100">
<RouteMapCanvas :entries="entries" />

<div
v-if="!loading && entries.length === 0 && !failed"
class="absolute inset-0 z-10 flex items-center justify-center bg-base-100/80 pointer-events-none"
>
<span class="alert alert-warning w-auto">{{ trans('route-map.empty') }}</span>
</div>
<div
v-if="loading"
class="absolute inset-0 z-10 flex items-center justify-center bg-base-100/60 pointer-events-none"
>
<span class="loading loading-spinner loading-lg" />
</div>
</div>
</div>
</div>
</AppLayout>
</template>
50 changes: 50 additions & 0 deletions resources/tailwind-app/pages/RouteMap/categoryColors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { HafasTravelType } from '../../../types/Api.gen';

const CATEGORY_COLORS: Record<HafasTravelType, string> = {
[HafasTravelType.NationalExpress]: '#c72730',
[HafasTravelType.National]: '#f97316',
[HafasTravelType.RegionalExp]: '#eab308',
[HafasTravelType.Regional]: '#22c55e',
[HafasTravelType.Suburban]: '#14b8a6',
[HafasTravelType.Subway]: '#3b82f6',
[HafasTravelType.Tram]: '#a855f7',
[HafasTravelType.Bus]: '#ec4899',
[HafasTravelType.Ferry]: '#06b6d4',
[HafasTravelType.Plane]: '#64748b',
[HafasTravelType.Taxi]: '#84cc16',
[HafasTravelType.FreightTrain]: '#78716c',
};

const FALLBACK_COLOR = '#9ca3af';

export const CATEGORY_ORDER: HafasTravelType[] = [
HafasTravelType.NationalExpress,
HafasTravelType.National,
HafasTravelType.RegionalExp,
HafasTravelType.Regional,
HafasTravelType.Suburban,
HafasTravelType.Subway,
HafasTravelType.Tram,
HafasTravelType.Bus,
HafasTravelType.Ferry,
HafasTravelType.Plane,
HafasTravelType.Taxi,
HafasTravelType.FreightTrain,
];

export function categoryColor(category?: HafasTravelType | null): string {
return (category && CATEGORY_COLORS[category]) || FALLBACK_COLOR;
}

/**
* multiple categories = fastes wins
*/
export function primaryCategory(categories: HafasTravelType[]): HafasTravelType | null {
for (const category of CATEGORY_ORDER) {
if (categories.includes(category)) {
return category;
}
}

return null;
}
53 changes: 53 additions & 0 deletions resources/tailwind-app/pages/RouteMap/filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Business, HafasTravelType } from '../../../types/Api.gen';

export interface RouteMapFilterState {
from: string;
until: string;
travelTypes: HafasTravelType[];
travelPurposes: Business[];
includeApproximated: boolean;
}

export const TRAVEL_PURPOSES: { value: Business; label: string }[] = [
{ value: Business.Value0, label: 'stationboard.business.private' },
{ value: Business.Value1, label: 'stationboard.business.business' },
{ value: Business.Value2, label: 'stationboard.business.commute' },
];

export function defaultFilterState(): RouteMapFilterState {
return {
from: '',
until: '',
travelTypes: [],
travelPurposes: [],
includeApproximated: true,
};
}

export function isFilterActive(state: RouteMapFilterState): boolean {
return (
state.from !== '' ||
state.until !== '' ||
state.travelTypes.length > 0 ||
state.travelPurposes.length > 0 ||
!state.includeApproximated
);
}

export interface RouteMapQuery {
from?: string;
until?: string;
'travelTypes[]'?: HafasTravelType[];
'travelPurposes[]'?: Business[];
includeApproximated?: boolean;
}

export function toQuery(state: RouteMapFilterState): RouteMapQuery {
return {
from: state.from || undefined,
until: state.until || undefined,
'travelTypes[]': state.travelTypes.length > 0 ? state.travelTypes : undefined,
'travelPurposes[]': state.travelPurposes.length > 0 ? state.travelPurposes : undefined,
includeApproximated: state.includeApproximated ? undefined : false,
};
}
Loading
Loading