diff --git a/lang/de.json b/lang/de.json index 94d156972..6b0201d6b 100644 --- a/lang/de.json +++ b/lang/de.json @@ -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." } diff --git a/lang/en.json b/lang/en.json index 8593b005a..99ecc2de3 100644 --- a/lang/en.json +++ b/lang/en.json @@ -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." } diff --git a/lang/fr.json b/lang/fr.json index 17e805bdd..884964be0 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -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." } diff --git a/resources/tailwind-app/helpers/polyline.ts b/resources/tailwind-app/helpers/polyline.ts new file mode 100644 index 000000000..0cf46ded4 --- /dev/null +++ b/resources/tailwind-app/helpers/polyline.ts @@ -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; +} diff --git a/resources/tailwind-app/layouts/AppLayout.vue b/resources/tailwind-app/layouts/AppLayout.vue index 74f44728b..8b236c028 100644 --- a/resources/tailwind-app/layouts/AppLayout.vue +++ b/resources/tailwind-app/layouts/AppLayout.vue @@ -226,6 +226,7 @@ import { ShieldCogCorner, Ticket, User, + Waypoints, X, } from '@lucide/vue'; import { trans } from 'laravel-vue-i18n'; @@ -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 }, diff --git a/resources/tailwind-app/pages/RouteMap/RouteMap.vue b/resources/tailwind-app/pages/RouteMap/RouteMap.vue new file mode 100644 index 000000000..6ad33b20a --- /dev/null +++ b/resources/tailwind-app/pages/RouteMap/RouteMap.vue @@ -0,0 +1,109 @@ + + + + + + + + {{ trans('route-map.title') }} + + + + {{ trans('route-map.description') }} + + + + + + + + + + {{ trans('error.500') }} + + + + + + + {{ trans('route-map.empty') }} + + + + + + + + + diff --git a/resources/tailwind-app/pages/RouteMap/categoryColors.ts b/resources/tailwind-app/pages/RouteMap/categoryColors.ts new file mode 100644 index 000000000..a8025ec75 --- /dev/null +++ b/resources/tailwind-app/pages/RouteMap/categoryColors.ts @@ -0,0 +1,50 @@ +import { HafasTravelType } from '../../../types/Api.gen'; + +const CATEGORY_COLORS: Record = { + [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; +} diff --git a/resources/tailwind-app/pages/RouteMap/filters.ts b/resources/tailwind-app/pages/RouteMap/filters.ts new file mode 100644 index 000000000..f1917bd64 --- /dev/null +++ b/resources/tailwind-app/pages/RouteMap/filters.ts @@ -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, + }; +} diff --git a/resources/tailwind-app/pages/RouteMap/partials/RouteMapCanvas.vue b/resources/tailwind-app/pages/RouteMap/partials/RouteMapCanvas.vue new file mode 100644 index 000000000..3445f5ae3 --- /dev/null +++ b/resources/tailwind-app/pages/RouteMap/partials/RouteMapCanvas.vue @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + diff --git a/resources/tailwind-app/pages/RouteMap/partials/RouteMapFilters.vue b/resources/tailwind-app/pages/RouteMap/partials/RouteMapFilters.vue new file mode 100644 index 000000000..181933664 --- /dev/null +++ b/resources/tailwind-app/pages/RouteMap/partials/RouteMapFilters.vue @@ -0,0 +1,129 @@ + + + + + + + + + {{ trans('filter.title') }} + + + + {{ trans('filter.reset') }} + + + + + {{ trans('filter.period') }} + + + {{ trans('stats.from') }} + + + + {{ trans('stats.to') }} + + + + + + + {{ trans('filter.travel-type') }} + + + + + {{ trans('transport_types.' + category) }} + + + + + + {{ trans('filter.travel-purpose') }} + + + {{ trans(purpose.label) }} + + + + + + + + {{ trans('route-map.include-approximated') }} + {{ trans('route-map.approximated.hint') }} + + + + + diff --git a/resources/tailwind-app/router/routes.ts b/resources/tailwind-app/router/routes.ts index d1944ef41..48a9c08cb 100644 --- a/resources/tailwind-app/router/routes.ts +++ b/resources/tailwind-app/router/routes.ts @@ -18,6 +18,7 @@ import MonthlyLeaderboard from '../pages/Leaderboard/MonthlyLeaderboard.vue'; import Notifications from '../pages/Notifications/Notifications.vue'; import PrivacyPolicy from '../pages/PrivacyPolicy/PrivacyPolicy.vue'; import UserProfile from '../pages/Profile/UserProfile.vue'; +import RouteMap from '../pages/RouteMap/RouteMap.vue'; import Search from '../pages/Search/Search.vue'; import Account from '../pages/Settings/Account/Account.vue'; import Applications from '../pages/Settings/Applications/Applications.vue'; @@ -173,6 +174,12 @@ const routes: Array = [ component: Statistics, meta: { title: 'stats' }, }, + { + path: '/route-map', + name: 'route-map', + component: RouteMap, + meta: { title: 'route-map.title' }, + }, { path: '/statistics/daily/:dateString', name: 'stats-daily',
{{ trans('route-map.description') }}
{{ trans('filter.period') }}
{{ trans('filter.travel-type') }}
{{ trans('filter.travel-purpose') }}