mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(roadtrip): one ride-gap rule for both corridor searches, and two seam edges
The corridor search in the browser cut its tiles around a ride, the MCP tool did not: it tiled and measured along the straight line between two terminals, so an assistant searching a flight day got boxes and hits under the flight path. The gap logic moves into shared/roadtrip/corridor.ts and both sides use it. A departure less than the check-in allowance after midnight no longer pins on the previous evening, and a hire car whose pick-up desk has no position is not seamed at its return desk.
This commit is contained in:
@@ -198,6 +198,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'البحث عبر Google فقط',
|
||||
'admin.placesGoogleOnly.subtitle': 'كل بحث وكل اقتراح يذهب إلى Google Places. عند الإيقاف يجيب فهرس TREK وOpenStreetMap أولًا، ولا يُسأل Google إلا إذا لم يجدا شيئًا.',
|
||||
'admin.placesGoogleOnly.missingKey': 'يتطلب مفتاح Google Maps API. من دونه يعمل البحث عبر فهرس TREK وOpenStreetMap مهما كان وضع هذا المفتاح.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'يتطلب Google كمزود للأماكن. عند اختيار Amap أو OpenStreetMap لا يذهب أي بحث إلى Google مهما كان وضع هذا المفتاح.',
|
||||
'admin.transitProvider.title': 'مزود النقل العام',
|
||||
'admin.transitProvider.subtitle': 'الخدمة التي تجيب على بحث النقل العام.',
|
||||
'admin.transitProvider.transitous': 'Transitous (مجاني)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'حذف الرابط',
|
||||
'collab.links.edit': 'تعديل الرابط',
|
||||
'collab.links.open': 'فتح الرابط',
|
||||
'collab.links.confirmDeleteTitle': 'حذف الرابط؟',
|
||||
'collab.links.confirmDeleteBody': 'سيتم حذف هذا الرابط نهائيًا.',
|
||||
'collab.whatsNext.title': 'ما التالي',
|
||||
'collab.whatsNext.today': 'اليوم',
|
||||
'collab.whatsNext.tomorrow': 'غدًا',
|
||||
|
||||
@@ -176,6 +176,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Pesquisar apenas com o Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Toda pesquisa e toda sugestão vão para o Google Places. Desligado, o índice do TREK e o OpenStreetMap respondem primeiro, e o Google só é consultado se eles não encontrarem nada.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Precisa de uma chave de API do Google Maps. Sem ela, a pesquisa usa o índice do TREK e o OpenStreetMap, independentemente desta chave.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Precisa do Google como provedor de lugares. Com Amap ou OpenStreetMap selecionado, nenhuma busca vai ao Google, seja qual for a posição deste botão.',
|
||||
'admin.transitProvider.title': 'Provedor de transporte público',
|
||||
'admin.transitProvider.subtitle': 'Qual serviço responde à busca de transporte público.',
|
||||
'admin.transitProvider.transitous': 'Transitous (grátis)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Excluir link',
|
||||
'collab.links.edit': 'Editar link',
|
||||
'collab.links.open': 'Abrir link',
|
||||
'collab.links.confirmDeleteTitle': 'Excluir link?',
|
||||
'collab.links.confirmDeleteBody': 'Este link será excluído permanentemente.',
|
||||
'collab.whatsNext.title': 'Próximos passos',
|
||||
'collab.whatsNext.today': 'Hoje',
|
||||
'collab.whatsNext.tomorrow': 'Amanhã',
|
||||
|
||||
@@ -169,6 +169,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Cercar només amb Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Cada cerca i cada suggeriment van a Google Places. Desactivat, responen primer l\'índex de TREK i OpenStreetMap, i Google només es consulta si no troben res.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Cal una clau d\'API de Google Maps. Sense clau, la cerca fa servir l\'índex de TREK i OpenStreetMap, sigui com sigui aquest interruptor.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Necessita Google com a proveïdor de llocs. Amb Amap o OpenStreetMap seleccionats, cap cerca va a Google, digui el que digui aquest interruptor.',
|
||||
'admin.transitProvider.title': 'Proveïdor de transport públic',
|
||||
'admin.transitProvider.subtitle': 'Quin servei respon la cerca de transport públic.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratuït)',
|
||||
|
||||
@@ -17,6 +17,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Suprimeix l\'enllaç',
|
||||
'collab.links.edit': 'Edita l\'enllaç',
|
||||
'collab.links.open': 'Obre l\'enllaç',
|
||||
'collab.links.confirmDeleteTitle': 'Suprimir l\'enllaç?',
|
||||
'collab.links.confirmDeleteBody': 'Aquest enllaç se suprimirà permanentment.',
|
||||
'collab.whatsNext.title': 'Què ve ara',
|
||||
'collab.whatsNext.today': 'Avui',
|
||||
'collab.whatsNext.tomorrow': 'Demà',
|
||||
|
||||
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Hledat pouze přes Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Každé hledání a každý návrh jde do Google Places. Vypnuto: nejprve odpovídá index TREK a OpenStreetMap, Google se ptáme jen tehdy, když nic nenajdou.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Vyžaduje klíč Google Maps API. Bez něj hledání běží přes index TREK a OpenStreetMap bez ohledu na tento přepínač.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Vyžaduje Google jako poskytovatele míst. Při zvoleném Amapu nebo OpenStreetMap nejde na Google žádné hledání, ať je tento přepínač nastaven jakkoli.',
|
||||
'admin.transitProvider.title': 'Poskytovatel veřejné dopravy',
|
||||
'admin.transitProvider.subtitle': 'Která služba odpovídá na vyhledávání veřejné dopravy.',
|
||||
'admin.transitProvider.transitous': 'Transitous (zdarma)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Smazat odkaz',
|
||||
'collab.links.edit': 'Upravit odkaz',
|
||||
'collab.links.open': 'Otevřít odkaz',
|
||||
'collab.links.confirmDeleteTitle': 'Smazat odkaz?',
|
||||
'collab.links.confirmDeleteBody': 'Tento odkaz bude trvale smazán.',
|
||||
'collab.whatsNext.title': 'Co následuje',
|
||||
'collab.whatsNext.today': 'Dnes',
|
||||
'collab.whatsNext.tomorrow': 'Zítra',
|
||||
|
||||
@@ -179,6 +179,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Nur mit Google suchen',
|
||||
'admin.placesGoogleOnly.subtitle': 'Jede Suche und jeder Vorschlag geht an Google Places. Aus, antworten zuerst der TREK-Index und OpenStreetMap, Google wird nur gefragt, wenn beide nichts finden.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Braucht einen Google-Maps-API-Schlüssel. Ohne ihn läuft die Suche über den TREK-Index und OpenStreetMap, egal wie dieser Schalter steht.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Braucht Google als Orts-Anbieter. Mit Amap oder OpenStreetMap als Auswahl geht keine Suche an Google, egal wie dieser Schalter steht.',
|
||||
'admin.transitProvider.title': 'Verkehrsanbieter',
|
||||
'admin.transitProvider.subtitle': 'Welcher Dienst die ÖPNV-Suche beantwortet.',
|
||||
'admin.transitProvider.transitous': 'Transitous (kostenlos)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Link löschen',
|
||||
'collab.links.edit': 'Link bearbeiten',
|
||||
'collab.links.open': 'Link öffnen',
|
||||
'collab.links.confirmDeleteTitle': 'Link löschen?',
|
||||
'collab.links.confirmDeleteBody': 'Dieser Link wird dauerhaft gelöscht.',
|
||||
'collab.whatsNext.title': 'Nächste',
|
||||
'collab.whatsNext.today': 'Heute',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
|
||||
@@ -218,6 +218,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Search with Google only',
|
||||
'admin.placesGoogleOnly.subtitle': 'Every search and every suggestion goes to Google Places. Off, TREK\'s own index and OpenStreetMap answer first and Google is only asked when they find nothing.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Needs a Google Maps API key. Without one, search runs on TREK\'s own index and OpenStreetMap whatever this switch says.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Needs Google as the places provider. With Amap or OpenStreetMap picked, search never goes to Google whatever this switch says.',
|
||||
'admin.transitProvider.title': 'Transit Provider',
|
||||
'admin.transitProvider.subtitle': 'Which service answers public transit search.',
|
||||
'admin.transitProvider.transitous': 'Transitous (free)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Delete link',
|
||||
'collab.links.edit': 'Edit link',
|
||||
'collab.links.open': 'Open link',
|
||||
'collab.links.confirmDeleteTitle': 'Delete link?',
|
||||
'collab.links.confirmDeleteBody': 'This link will be permanently deleted.',
|
||||
'collab.whatsNext.title': "What's Next",
|
||||
'collab.whatsNext.today': 'Today',
|
||||
'collab.whatsNext.tomorrow': 'Tomorrow',
|
||||
|
||||
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Buscar solo con Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Cada búsqueda y cada sugerencia van a Google Places. Desactivado, responden primero el índice de TREK y OpenStreetMap, y Google solo se consulta si no encuentran nada.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Necesita una clave de API de Google Maps. Sin ella, la búsqueda usa el índice de TREK y OpenStreetMap, esté como esté este interruptor.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Necesita Google como proveedor de lugares. Con Amap u OpenStreetMap seleccionados, ninguna búsqueda va a Google, diga lo que diga este interruptor.',
|
||||
'admin.transitProvider.title': 'Proveedor de transporte público',
|
||||
'admin.transitProvider.subtitle': 'Qué servicio responde a la búsqueda de transporte público.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratis)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Eliminar enlace',
|
||||
'collab.links.edit': 'Editar enlace',
|
||||
'collab.links.open': 'Abrir enlace',
|
||||
'collab.links.confirmDeleteTitle': '¿Eliminar el enlace?',
|
||||
'collab.links.confirmDeleteBody': 'Este enlace se eliminará permanentemente.',
|
||||
'collab.whatsNext.title': 'Qué viene ahora',
|
||||
'collab.whatsNext.today': 'Hoy',
|
||||
'collab.whatsNext.tomorrow': 'Mañana',
|
||||
|
||||
@@ -180,6 +180,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Rechercher uniquement avec Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Chaque recherche et chaque suggestion passent par Google Places. Désactivé, l\'index de TREK et OpenStreetMap répondent d\'abord, Google n\'est interrogé que s\'ils ne trouvent rien.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Nécessite une clé API Google Maps. Sans elle, la recherche passe par l\'index de TREK et OpenStreetMap, quelle que soit la position de cet interrupteur.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Nécessite Google comme fournisseur de lieux. Avec Amap ou OpenStreetMap sélectionné, aucune recherche ne part vers Google, quelle que soit la position de cet interrupteur.',
|
||||
'admin.transitProvider.title': 'Fournisseur de transports en commun',
|
||||
'admin.transitProvider.subtitle': 'Quel service répond à la recherche de transports en commun.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratuit)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Supprimer le lien',
|
||||
'collab.links.edit': 'Modifier le lien',
|
||||
'collab.links.open': 'Ouvrir le lien',
|
||||
'collab.links.confirmDeleteTitle': 'Supprimer le lien ?',
|
||||
'collab.links.confirmDeleteBody': 'Ce lien sera définitivement supprimé.',
|
||||
'collab.whatsNext.title': 'À venir',
|
||||
'collab.whatsNext.today': "Aujourd'hui",
|
||||
'collab.whatsNext.tomorrow': 'Demain',
|
||||
|
||||
@@ -226,6 +226,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Αναζήτηση μόνο με Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Κάθε αναζήτηση και κάθε πρόταση πηγαίνει στο Google Places. Απενεργοποιημένο, απαντούν πρώτα το ευρετήριο του TREK και το OpenStreetMap, και το Google ρωτιέται μόνο αν δεν βρουν τίποτα.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Χρειάζεται κλειδί Google Maps API. Χωρίς αυτό η αναζήτηση τρέχει στο ευρετήριο του TREK και στο OpenStreetMap, όπως κι αν είναι ο διακόπτης.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Χρειάζεται το Google ως πάροχο τοποθεσιών. Με επιλεγμένο Amap ή OpenStreetMap καμία αναζήτηση δεν πηγαίνει στο Google, ό,τι κι αν λέει αυτός ο διακόπτης.',
|
||||
'admin.transitProvider.title': 'Πάροχος μέσων μεταφοράς',
|
||||
'admin.transitProvider.subtitle': 'Ποια υπηρεσία απαντά στην αναζήτηση μέσων μεταφοράς.',
|
||||
'admin.transitProvider.transitous': 'Transitous (δωρεάν)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Διαγραφή συνδέσμου',
|
||||
'collab.links.edit': 'Επεξεργασία συνδέσμου',
|
||||
'collab.links.open': 'Άνοιγμα συνδέσμου',
|
||||
'collab.links.confirmDeleteTitle': 'Διαγραφή συνδέσμου;',
|
||||
'collab.links.confirmDeleteBody': 'Αυτός ο σύνδεσμος θα διαγραφεί οριστικά.',
|
||||
'collab.whatsNext.title': 'Τι Επόμενο',
|
||||
'collab.whatsNext.today': 'Σήμερα',
|
||||
'collab.whatsNext.tomorrow': 'Αύριο',
|
||||
|
||||
@@ -180,6 +180,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Keresés csak a Google-lel',
|
||||
'admin.placesGoogleOnly.subtitle': 'Minden keresés és minden javaslat a Google Places-hez megy. Kikapcsolva előbb a TREK saját indexe és az OpenStreetMap válaszol, a Google-t csak akkor kérdezzük, ha ők nem találnak semmit.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Google Maps API-kulcs kell hozzá. Nélküle a keresés a TREK indexén és az OpenStreetMapen fut, bárhogy áll is ez a kapcsoló.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'A Google-t igényli helyszolgáltatóként. Amap vagy OpenStreetMap kiválasztásával egyetlen keresés sem megy a Google-höz, bárhogy áll is ez a kapcsoló.',
|
||||
'admin.transitProvider.title': 'Tömegközlekedési szolgáltató',
|
||||
'admin.transitProvider.subtitle': 'Melyik szolgáltatás válaszol a tömegközlekedési keresésre.',
|
||||
'admin.transitProvider.transitous': 'Transitous (ingyenes)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Link törlése',
|
||||
'collab.links.edit': 'Link szerkesztése',
|
||||
'collab.links.open': 'Link megnyitása',
|
||||
'collab.links.confirmDeleteTitle': 'Törlöd a linket?',
|
||||
'collab.links.confirmDeleteBody': 'Ez a link véglegesen törlődik.',
|
||||
'collab.whatsNext.title': 'Mi következik',
|
||||
'collab.whatsNext.today': 'Ma',
|
||||
'collab.whatsNext.tomorrow': 'Holnap',
|
||||
|
||||
@@ -222,6 +222,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Cari hanya dengan Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Setiap pencarian dan setiap saran dikirim ke Google Places. Nonaktif, indeks TREK dan OpenStreetMap menjawab lebih dulu, dan Google hanya ditanya jika keduanya tidak menemukan apa pun.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Memerlukan kunci API Google Maps. Tanpa kunci, pencarian memakai indeks TREK dan OpenStreetMap, apa pun posisi sakelar ini.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Membutuhkan Google sebagai penyedia tempat. Dengan Amap atau OpenStreetMap yang dipilih, tidak ada pencarian yang dikirim ke Google, apa pun posisi sakelar ini.',
|
||||
'admin.transitProvider.title': 'Penyedia transportasi umum',
|
||||
'admin.transitProvider.subtitle': 'Layanan mana yang menjawab pencarian transportasi umum.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratis)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Hapus tautan',
|
||||
'collab.links.edit': 'Edit tautan',
|
||||
'collab.links.open': 'Buka tautan',
|
||||
'collab.links.confirmDeleteTitle': 'Hapus tautan?',
|
||||
'collab.links.confirmDeleteBody': 'Tautan ini akan dihapus secara permanen.',
|
||||
'collab.whatsNext.title': 'Berikutnya',
|
||||
'collab.whatsNext.today': 'Hari ini',
|
||||
'collab.whatsNext.tomorrow': 'Besok',
|
||||
|
||||
@@ -178,6 +178,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Cerca solo con Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Ogni ricerca e ogni suggerimento vanno a Google Places. Spento, rispondono prima l\'indice di TREK e OpenStreetMap, e Google viene interrogato solo se non trovano nulla.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Richiede una chiave API di Google Maps. Senza, la ricerca usa l\'indice di TREK e OpenStreetMap, comunque sia impostato questo interruttore.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Richiede Google come provider dei luoghi. Con Amap o OpenStreetMap selezionati, nessuna ricerca va a Google, qualunque sia la posizione di questo interruttore.',
|
||||
'admin.transitProvider.title': 'Provider del trasporto pubblico',
|
||||
'admin.transitProvider.subtitle': 'Quale servizio risponde alla ricerca di trasporto pubblico.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratuito)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Elimina link',
|
||||
'collab.links.edit': 'Modifica link',
|
||||
'collab.links.open': 'Apri link',
|
||||
'collab.links.confirmDeleteTitle': 'Eliminare il link?',
|
||||
'collab.links.confirmDeleteBody': 'Questo link verrà eliminato definitivamente.',
|
||||
'collab.whatsNext.title': "Cosa c'è dopo",
|
||||
'collab.whatsNext.today': 'Oggi',
|
||||
'collab.whatsNext.tomorrow': 'Domani',
|
||||
|
||||
@@ -211,6 +211,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Google だけで検索',
|
||||
'admin.placesGoogleOnly.subtitle': 'すべての検索と候補が Google Places に送られます。オフの場合は TREK のインデックスと OpenStreetMap が先に答え、何も見つからないときだけ Google に問い合わせます。',
|
||||
'admin.placesGoogleOnly.missingKey': 'Google Maps API キーが必要です。キーがない場合、このスイッチに関係なく検索は TREK のインデックスと OpenStreetMap で行われます。',
|
||||
'admin.placesGoogleOnly.otherProvider': '場所プロバイダーとして Google が必要です。Amap または OpenStreetMap を選択している間は、このスイッチの状態にかかわらず検索は Google に送られません。',
|
||||
'admin.transitProvider.title': '公共交通のプロバイダー',
|
||||
'admin.transitProvider.subtitle': '公共交通の検索に応答するサービス。',
|
||||
'admin.transitProvider.transitous': 'Transitous(無料)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'リンクを削除',
|
||||
'collab.links.edit': 'リンクを編集',
|
||||
'collab.links.open': 'リンクを開く',
|
||||
'collab.links.confirmDeleteTitle': 'リンクを削除しますか?',
|
||||
'collab.links.confirmDeleteBody': 'このリンクは完全に削除されます。',
|
||||
'collab.whatsNext.title': '次にすること',
|
||||
'collab.whatsNext.today': '今日',
|
||||
'collab.whatsNext.tomorrow': '明日',
|
||||
|
||||
@@ -217,6 +217,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Google로만 검색',
|
||||
'admin.placesGoogleOnly.subtitle': '모든 검색과 제안이 Google Places로 갑니다. 끄면 TREK 자체 색인과 OpenStreetMap이 먼저 답하고, 둘 다 찾지 못할 때만 Google에 묻습니다.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Google Maps API 키가 필요합니다. 키가 없으면 이 스위치와 상관없이 TREK 색인과 OpenStreetMap으로 검색합니다.',
|
||||
'admin.placesGoogleOnly.otherProvider': '장소 제공자로 Google이 필요합니다. Amap 또는 OpenStreetMap을 선택한 동안에는 이 스위치와 상관없이 검색이 Google로 가지 않습니다.',
|
||||
'admin.transitProvider.title': '대중교통 제공자',
|
||||
'admin.transitProvider.subtitle': '대중교통 검색에 응답하는 서비스입니다.',
|
||||
'admin.transitProvider.transitous': 'Transitous (무료)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': '링크 삭제',
|
||||
'collab.links.edit': '링크 편집',
|
||||
'collab.links.open': '링크 열기',
|
||||
'collab.links.confirmDeleteTitle': '링크를 삭제할까요?',
|
||||
'collab.links.confirmDeleteBody': '이 링크는 영구적으로 삭제됩니다.',
|
||||
'collab.whatsNext.title': '다음 할 일',
|
||||
'collab.whatsNext.today': '오늘',
|
||||
'collab.whatsNext.tomorrow': '내일',
|
||||
|
||||
@@ -179,6 +179,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Alleen met Google zoeken',
|
||||
'admin.placesGoogleOnly.subtitle': 'Elke zoekopdracht en elke suggestie gaat naar Google Places. Uit, antwoorden eerst de TREK-index en OpenStreetMap; Google wordt alleen gevraagd als die niets vinden.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Vereist een Google Maps API-sleutel. Zonder sleutel zoekt TREK via de eigen index en OpenStreetMap, wat deze schakelaar ook zegt.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Vereist Google als plaatsenprovider. Met Amap of OpenStreetMap gekozen gaat geen enkele zoekopdracht naar Google, wat deze schakelaar ook zegt.',
|
||||
'admin.transitProvider.title': 'Ov-provider',
|
||||
'admin.transitProvider.subtitle': 'Welke dienst het ov-zoeken beantwoordt.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratis)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Link verwijderen',
|
||||
'collab.links.edit': 'Link bewerken',
|
||||
'collab.links.open': 'Link openen',
|
||||
'collab.links.confirmDeleteTitle': 'Link verwijderen?',
|
||||
'collab.links.confirmDeleteBody': 'Deze link wordt definitief verwijderd.',
|
||||
'collab.whatsNext.title': 'Wat komt er',
|
||||
'collab.whatsNext.today': 'Vandaag',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
|
||||
@@ -173,6 +173,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Szukaj tylko przez Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Każde wyszukiwanie i każda podpowiedź trafia do Google Places. Wyłączone: najpierw odpowiadają indeks TREK i OpenStreetMap, a Google jest pytany tylko wtedy, gdy nic nie znajdą.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Wymaga klucza Google Maps API. Bez niego wyszukiwanie działa przez indeks TREK i OpenStreetMap, niezależnie od tego przełącznika.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Wymaga Google jako dostawcy miejsc. Przy wybranym Amap lub OpenStreetMap żadne wyszukiwanie nie trafia do Google, niezależnie od tego przełącznika.',
|
||||
'admin.transitProvider.title': 'Dostawca transportu publicznego',
|
||||
'admin.transitProvider.subtitle': 'Która usługa odpowiada na wyszukiwanie transportu publicznego.',
|
||||
'admin.transitProvider.transitous': 'Transitous (bezpłatnie)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Usuń link',
|
||||
'collab.links.edit': 'Edytuj link',
|
||||
'collab.links.open': 'Otwórz link',
|
||||
'collab.links.confirmDeleteTitle': 'Usunąć link?',
|
||||
'collab.links.confirmDeleteBody': 'Ten link zostanie trwale usunięty.',
|
||||
'collab.whatsNext.title': 'Co dalej',
|
||||
'collab.whatsNext.today': 'Dzisiaj',
|
||||
'collab.whatsNext.tomorrow': 'Jutro',
|
||||
|
||||
@@ -180,6 +180,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Искать только через Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Каждый поиск и каждая подсказка идут в Google Places. Выключено: сначала отвечают индекс TREK и OpenStreetMap, Google спрашивается, только если они ничего не нашли.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Нужен ключ Google Maps API. Без него поиск идёт через индекс TREK и OpenStreetMap независимо от этого переключателя.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Требуется Google как провайдер мест. Если выбран Amap или OpenStreetMap, ни один поиск не уходит в Google, как бы ни стоял этот переключатель.',
|
||||
'admin.transitProvider.title': 'Поставщик общественного транспорта',
|
||||
'admin.transitProvider.subtitle': 'Какая служба отвечает на поиск общественного транспорта.',
|
||||
'admin.transitProvider.transitous': 'Transitous (бесплатно)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Удалить ссылку',
|
||||
'collab.links.edit': 'Изменить ссылку',
|
||||
'collab.links.open': 'Открыть ссылку',
|
||||
'collab.links.confirmDeleteTitle': 'Удалить ссылку?',
|
||||
'collab.links.confirmDeleteBody': 'Эта ссылка будет удалена без возможности восстановления.',
|
||||
'collab.whatsNext.title': 'Что дальше',
|
||||
'collab.whatsNext.today': 'Сегодня',
|
||||
'collab.whatsNext.tomorrow': 'Завтра',
|
||||
|
||||
@@ -224,6 +224,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Sök bara med Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Varje sökning och varje förslag går till Google Places. Av svarar TREK:s eget index och OpenStreetMap först, och Google tillfrågas bara när de inte hittar något.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Kräver en Google Maps API-nyckel. Utan nyckel söker TREK via sitt eget index och OpenStreetMap, oavsett hur den här brytaren står.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Kräver Google som platsleverantör. Med Amap eller OpenStreetMap valt går ingen sökning till Google, oavsett hur den här väljaren står.',
|
||||
'admin.transitProvider.title': 'Kollektivtrafikleverantör',
|
||||
'admin.transitProvider.subtitle': 'Vilken tjänst som besvarar sökningar i kollektivtrafiken.',
|
||||
'admin.transitProvider.transitous': 'Transitous (gratis)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Ta bort länk',
|
||||
'collab.links.edit': 'Redigera länk',
|
||||
'collab.links.open': 'Öppna länk',
|
||||
'collab.links.confirmDeleteTitle': 'Ta bort länken?',
|
||||
'collab.links.confirmDeleteBody': 'Den här länken tas bort permanent.',
|
||||
'collab.whatsNext.title': 'Vad händer härnäst?',
|
||||
'collab.whatsNext.today': 'Idag',
|
||||
'collab.whatsNext.tomorrow': 'I morgon',
|
||||
|
||||
@@ -224,6 +224,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Yalnızca Google ile ara',
|
||||
'admin.placesGoogleOnly.subtitle': 'Her arama ve her öneri Google Places\'e gider. Kapalıyken önce TREK dizini ve OpenStreetMap yanıtlar, Google yalnızca hiçbir şey bulamadıklarında sorulur.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Google Maps API anahtarı gerektirir. Anahtar yoksa arama, bu anahtar ne olursa olsun TREK dizini ve OpenStreetMap üzerinden çalışır.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Yer sağlayıcısı olarak Google gerekir. Amap veya OpenStreetMap seçiliyken bu anahtar ne derse desin hiçbir arama Google\'a gitmez.',
|
||||
'admin.transitProvider.title': 'Toplu taşıma sağlayıcısı',
|
||||
'admin.transitProvider.subtitle': 'Toplu taşıma aramasını hangi hizmetin yanıtlayacağı.',
|
||||
'admin.transitProvider.transitous': 'Transitous (ücretsiz)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Bağlantıyı sil',
|
||||
'collab.links.edit': 'Bağlantıyı düzenle',
|
||||
'collab.links.open': 'Bağlantıyı aç',
|
||||
'collab.links.confirmDeleteTitle': 'Bağlantı silinsin mi?',
|
||||
'collab.links.confirmDeleteBody': 'Bu bağlantı kalıcı olarak silinecek.',
|
||||
'collab.whatsNext.title': "What's Next",
|
||||
'collab.whatsNext.today': 'Bugün',
|
||||
'collab.whatsNext.tomorrow': 'Yarın',
|
||||
|
||||
@@ -180,6 +180,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Шукати лише через Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Кожен пошук і кожна підказка йдуть до Google Places. Вимкнено: спершу відповідають індекс TREK та OpenStreetMap, Google запитується лише тоді, коли вони нічого не знайшли.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Потрібен ключ Google Maps API. Без нього пошук іде через індекс TREK та OpenStreetMap незалежно від цього перемикача.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Потрібен Google як постачальник місць. Якщо вибрано Amap або OpenStreetMap, жоден пошук не йде до Google, хай як стоїть цей перемикач.',
|
||||
'admin.transitProvider.title': 'Постачальник громадського транспорту',
|
||||
'admin.transitProvider.subtitle': 'Яка служба відповідає на пошук громадського транспорту.',
|
||||
'admin.transitProvider.transitous': 'Transitous (безкоштовно)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Видалити посилання',
|
||||
'collab.links.edit': 'Редагувати посилання',
|
||||
'collab.links.open': 'Відкрити посилання',
|
||||
'collab.links.confirmDeleteTitle': 'Видалити посилання?',
|
||||
'collab.links.confirmDeleteBody': 'Це посилання буде видалено назавжди.',
|
||||
'collab.whatsNext.title': 'Що далі',
|
||||
'collab.whatsNext.today': 'Сьогодні',
|
||||
'collab.whatsNext.tomorrow': 'Завтра',
|
||||
|
||||
@@ -223,6 +223,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': 'Chỉ tìm bằng Google',
|
||||
'admin.placesGoogleOnly.subtitle': 'Mọi tìm kiếm và mọi gợi ý đều đi tới Google Places. Khi tắt, chỉ mục của TREK và OpenStreetMap trả lời trước, Google chỉ được hỏi khi cả hai không tìm thấy gì.',
|
||||
'admin.placesGoogleOnly.missingKey': 'Cần khóa API Google Maps. Không có khóa, tìm kiếm chạy trên chỉ mục của TREK và OpenStreetMap, bất kể công tắc này.',
|
||||
'admin.placesGoogleOnly.otherProvider': 'Cần Google làm nhà cung cấp địa điểm. Khi chọn Amap hoặc OpenStreetMap, không tìm kiếm nào được gửi đến Google dù công tắc này đặt thế nào.',
|
||||
'admin.transitProvider.title': 'Nhà cung cấp giao thông công cộng',
|
||||
'admin.transitProvider.subtitle': 'Dịch vụ nào trả lời tìm kiếm giao thông công cộng.',
|
||||
'admin.transitProvider.transitous': 'Transitous (miễn phí)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': 'Xóa liên kết',
|
||||
'collab.links.edit': 'Chỉnh sửa liên kết',
|
||||
'collab.links.open': 'Mở liên kết',
|
||||
'collab.links.confirmDeleteTitle': 'Xóa liên kết?',
|
||||
'collab.links.confirmDeleteBody': 'Liên kết này sẽ bị xóa vĩnh viễn.',
|
||||
'collab.whatsNext.title': 'Tiếp theo là gì',
|
||||
'collab.whatsNext.today': 'Hôm nay',
|
||||
'collab.whatsNext.tomorrow': 'Ngày mai',
|
||||
|
||||
@@ -214,6 +214,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': '僅使用 Google 搜尋',
|
||||
'admin.placesGoogleOnly.subtitle': '所有搜尋和建議都會送到 Google Places。關閉時,TREK 自有索引和 OpenStreetMap 先回答,只有兩者都沒有結果時才詢問 Google。',
|
||||
'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 金鑰。沒有金鑰時,無論此開關如何,搜尋都透過 TREK 索引和 OpenStreetMap 進行。',
|
||||
'admin.placesGoogleOnly.otherProvider': '需要將 Google 設為地點提供者。選擇 Amap 或 OpenStreetMap 時,無論此開關如何設定,搜尋都不會送往 Google。',
|
||||
'admin.transitProvider.title': '公共運輸資料來源',
|
||||
'admin.transitProvider.subtitle': '由哪個服務回應大眾運輸搜尋。',
|
||||
'admin.transitProvider.transitous': 'Transitous(免費)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': '刪除連結',
|
||||
'collab.links.edit': '編輯連結',
|
||||
'collab.links.open': '開啟連結',
|
||||
'collab.links.confirmDeleteTitle': '刪除連結?',
|
||||
'collab.links.confirmDeleteBody': '此連結將被永久刪除。',
|
||||
'collab.whatsNext.title': '接下來',
|
||||
'collab.whatsNext.today': '今天',
|
||||
'collab.whatsNext.tomorrow': '明天',
|
||||
|
||||
@@ -175,6 +175,7 @@ const admin: TranslationStrings = {
|
||||
'admin.placesGoogleOnly.title': '仅使用 Google 搜索',
|
||||
'admin.placesGoogleOnly.subtitle': '所有搜索和建议都发送到 Google Places。关闭时,TREK 自有索引和 OpenStreetMap 先回答,只有二者都没有结果时才询问 Google。',
|
||||
'admin.placesGoogleOnly.missingKey': '需要 Google Maps API 密钥。没有密钥时,无论此开关如何,搜索都通过 TREK 索引和 OpenStreetMap 进行。',
|
||||
'admin.placesGoogleOnly.otherProvider': '需要将 Google 设为地点提供方。选择 Amap 或 OpenStreetMap 时,无论此开关如何设置,搜索都不会发送到 Google。',
|
||||
'admin.transitProvider.title': '公共交通数据源',
|
||||
'admin.transitProvider.subtitle': '由哪个服务响应公共交通搜索。',
|
||||
'admin.transitProvider.transitous': 'Transitous(免费)',
|
||||
|
||||
@@ -25,6 +25,8 @@ const collab: TranslationStrings = {
|
||||
'collab.links.delete': '删除链接',
|
||||
'collab.links.edit': '编辑链接',
|
||||
'collab.links.open': '打开链接',
|
||||
'collab.links.confirmDeleteTitle': '删除链接?',
|
||||
'collab.links.confirmDeleteBody': '此链接将被永久删除。',
|
||||
'collab.whatsNext.title': '接下来',
|
||||
'collab.whatsNext.today': '今天',
|
||||
'collab.whatsNext.tomorrow': '明天',
|
||||
|
||||
@@ -30,7 +30,9 @@ export const mapsSearchRequestSchema = z.object({
|
||||
* Ask one provider alone for this search. The index and OpenStreetMap answer
|
||||
* first by default and Google is only asked when they find nothing; a caller
|
||||
* whose results were not the place they meant can send the same query to
|
||||
* Google instead. Ignored on an instance where Google holds no key.
|
||||
* Google instead. Ignored unless Google holds the keyed slot: without a
|
||||
* Google key, or with Amap or OpenStreetMap picked as the places provider,
|
||||
* the index and OpenStreetMap answer as usual.
|
||||
*/
|
||||
provider: z.enum(['google']).optional(),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* ROADTRIP-CARRIERS-001..019: a booking the traveller rides becomes a seam in the drive,
|
||||
* ROADTRIP-CARRIERS-001..021: a booking the traveller rides becomes a seam in the drive,
|
||||
* and a hire car puts its desks on it.
|
||||
*
|
||||
* The road ends at the terminal the ride leaves from and starts again at the one it
|
||||
@@ -15,11 +15,9 @@ import {
|
||||
carrierLeg,
|
||||
carrierLegsFor,
|
||||
carrierReservationIds,
|
||||
carrierRideMinutes,
|
||||
carrierSeam,
|
||||
carriesTheCar,
|
||||
isCarrierMode,
|
||||
isCarrierStop,
|
||||
isPickupStop,
|
||||
seatCarrierStops,
|
||||
terminalAssignmentId,
|
||||
@@ -196,7 +194,6 @@ describe('a hire car', () => {
|
||||
)!;
|
||||
expect(noDesk.arrival).toBeNull();
|
||||
expect(carrierSeam(rental({ endpoints: [] }))).toBeNull();
|
||||
expect(carrierRideMinutes(noDay, 0)).toBeNull();
|
||||
const seated = seatCarrierStops(1, [stop({ ownerIndex: 0, time: '10:00' })], [0], [noDay]);
|
||||
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['pickup', 'Stop 0']);
|
||||
expect(seatCarrierStops(3, [stop({ ownerIndex: 0 })], [0], [noDay]).map((s) => s.carrier?.role ?? s.name)).toEqual([
|
||||
@@ -222,7 +219,6 @@ describe('a hire car', () => {
|
||||
incomingLegMode: null,
|
||||
});
|
||||
expect(pickup.assignmentId).toBe(terminalAssignmentId(9, 'pickup'));
|
||||
expect(isCarrierStop(pickup)).toBe(true);
|
||||
expect(isPickupStop(pickup)).toBe(true);
|
||||
// No ride leaves a pick-up desk: the leg out of it is a road like any other.
|
||||
expect(carrierLegsFor([pickup, first[1]!], 'driving', () => 0, roadtripLegKey)).toBeNull();
|
||||
@@ -283,19 +279,52 @@ describe('a hire car', () => {
|
||||
expect(day.driveWarnings.some((w) => w.code === 'range')).toBe(false);
|
||||
expect(routes.totalStops).toBe(3);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-021: a hire car whose pick-up desk is not located stands nowhere, the return desk alone is no pick-up', () => {
|
||||
// The form and the booking import both drop an endpoint that did not geocode, so a
|
||||
// one-way hire can arrive with its return desk only. Reading that desk as the pick-up
|
||||
// started the drive in the city the car is handed back in.
|
||||
const returnOnly = rental({
|
||||
endpoints: [{ role: 'to', sequence: 1, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 }],
|
||||
});
|
||||
expect(carrierSeam(returnOnly)).toBeNull();
|
||||
const plannedStopOnly = rental({
|
||||
endpoints: [
|
||||
{ role: 'stop', sequence: 1, name: 'Luebeck', code: null, lat: 53.87, lng: 10.69 },
|
||||
{ role: 'to', sequence: 2, name: 'Sixt Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
|
||||
],
|
||||
});
|
||||
expect(carrierSeam(plannedStopOnly)).toBeNull();
|
||||
// A ride still takes the first located stop for its departure when none is marked.
|
||||
const unmarked = flight({
|
||||
endpoints: [
|
||||
{ role: 'stop', sequence: 0, name: 'Munich Airport', code: 'MUC', lat: 48.35, lng: 11.78 },
|
||||
{ role: 'to', sequence: 1, name: 'Hamburg Airport', code: 'HAM', lat: 53.63, lng: 9.99 },
|
||||
],
|
||||
});
|
||||
expect(carrierSeam(unmarked)!.departure.code).toBe('MUC');
|
||||
});
|
||||
});
|
||||
|
||||
describe('carrierRideMinutes', () => {
|
||||
describe('the minutes of a ride', () => {
|
||||
const rideOf = (booking: CarrierBooking, dayDelta: number): RoutedLeg => {
|
||||
const seam = carrierSeam(booking)!;
|
||||
const [dep] = seatCarrierStops(seam.departure.dayId, [], [], [seam]) as [RoadtripStop];
|
||||
const arr = seatCarrierStops(seam.arrival!.dayId, [], [], [seam]).find((s) => s.carrier?.role === 'arrival')!;
|
||||
return carrierLegsFor([dep, arr], 'flight', () => dayDelta, roadtripLegKey)![roadtripLegKey(dep, arr)]!;
|
||||
};
|
||||
|
||||
it('ROADTRIP-CARRIERS-007: the ride is what the clocks say, across the days the booking spans, and never negative', () => {
|
||||
const same = carrierSeam(flight())!;
|
||||
expect(carrierRideMinutes(same, 0)).toBe(70);
|
||||
const overnight = carrierSeam(flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 }))!;
|
||||
expect(carrierRideMinutes(overnight, 1)).toBe(540);
|
||||
expect(rideOf(flight(), 0).seg.duration).toBe(70 * 60);
|
||||
const overnight = flight({ reservation_time: '22:00', reservation_end_time: '07:00', end_day_id: 2 });
|
||||
expect(rideOf(overnight, 1).seg.duration).toBe(540 * 60);
|
||||
// Westward across the date line the clock lands before it left; the chain cannot
|
||||
// run backwards, so the ride counts as none rather than as a day.
|
||||
const dateLine = carrierSeam(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }))!;
|
||||
expect(carrierRideMinutes(dateLine, 0)).toBe(0);
|
||||
expect(carrierRideMinutes(carrierSeam(flight({ reservation_end_time: null }))!, 0)).toBeNull();
|
||||
expect(rideOf(flight({ reservation_time: '10:00', reservation_end_time: '06:00' }), 0).seg.duration).toBe(0);
|
||||
// Without an arrival clock there are no minutes to give, and the leg says so.
|
||||
const unclocked = rideOf(flight({ reservation_end_time: null }), 0);
|
||||
expect(unclocked.seg.duration).toBe(0);
|
||||
expect(unclocked.seg.durationText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -339,8 +368,6 @@ describe('seatCarrierStops', () => {
|
||||
expect(dep.placeId).toBeLessThan(0);
|
||||
expect(dep.ownerIndex).toBe(2);
|
||||
expect(arr.ownerIndex).toBe(2);
|
||||
expect(isCarrierStop(dep)).toBe(true);
|
||||
expect(isCarrierStop(day[0])).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-009: a dragged position decides among untimed stops, and a ride before every timed stop opens the day', () => {
|
||||
@@ -530,4 +557,64 @@ describe('the ride in the drive', () => {
|
||||
expect(chainTwo.schedule.entries[0]).toMatchObject({ arrival: '07:00' });
|
||||
expect(chainTwo.stops.some((s) => s.automaticNight)).toBe(false);
|
||||
});
|
||||
|
||||
it('ROADTRIP-CARRIERS-020: a red-eye inside its check-in allowance is pinned at midnight on its own day, and the day stays on one card', () => {
|
||||
// 00:30 less an hour is 23:30, which the clock wraps onto the evening before: the
|
||||
// chain then read the flight as leaving the next day and filed the arrival and every
|
||||
// stop after it on the next card, with a spill mark on a day nothing spilled from.
|
||||
const redEye = carrierSeam(flight({ reservation_time: '00:30', reservation_end_time: '02:30' }))!;
|
||||
const seated = seatCarrierStops(
|
||||
1,
|
||||
[
|
||||
stop({ ownerIndex: 0, time: '09:00', dwellMinutes: 0 }),
|
||||
stop({ ownerIndex: 1, time: '14:00', dwellMinutes: 0 }),
|
||||
],
|
||||
[0, 1],
|
||||
[redEye],
|
||||
);
|
||||
expect(seated.map((s) => s.carrier?.role ?? s.name)).toEqual(['departure', 'arrival', 'Stop 0', 'Stop 1']);
|
||||
const [dep, arr, first, second] = seated as [RoadtripStop, RoadtripStop, RoadtripStop, RoadtripStop];
|
||||
expect(dep).toMatchObject({ time: '00:00', leaveAt: '00:30', dwellMinutes: 30 });
|
||||
expect(arr.time).toBe('02:30');
|
||||
|
||||
const nextDay = [
|
||||
stop({ ownerIndex: 0, ownerDayId: 2, time: '10:00', dwellMinutes: 0, lat: 53.7 }),
|
||||
stop({ ownerIndex: 1, ownerDayId: 2, time: '12:00', dwellMinutes: 0, lat: 53.9 }),
|
||||
];
|
||||
const legs: Record<string, RoutedLeg> = {
|
||||
[roadtripLegKey(arr, first)]: road(30),
|
||||
[roadtripLegKey(first, second)]: road(30),
|
||||
[roadtripLegKey(nextDay[0]!, nextDay[1]!)]: road(30),
|
||||
...carrierLegsFor([dep, arr], 'flight', () => 0, roadtripLegKey)!,
|
||||
};
|
||||
const routes = assembleRoadtrip({
|
||||
plan: [
|
||||
{ dayId: 1, dayNumber: 1, date: '2026-06-01', title: null, stops: seated },
|
||||
{ dayId: 2, dayNumber: 2, date: '2026-06-02', title: null, stops: nextDay },
|
||||
],
|
||||
quietDays: [],
|
||||
window: null,
|
||||
distanceUnit: 'metric',
|
||||
allLegs: legs,
|
||||
snapByDay: {},
|
||||
missedByDay: {},
|
||||
loading: false,
|
||||
limits: { legMinutes: null, dayMinutes: null, rangeKm: null },
|
||||
vehicleKind: 'combustion',
|
||||
connectDays: false,
|
||||
boundaries: [],
|
||||
labels: { start: 'go', end: 'stop' },
|
||||
});
|
||||
expect(routes.days.map((d) => d.stops.map((s) => s.carrier?.role ?? s.name))).toEqual([
|
||||
['departure', 'arrival', 'Stop 0', 'Stop 1'],
|
||||
['Stop 0', 'Stop 1'],
|
||||
]);
|
||||
expect(routes.days[0]!.schedule.entries.map((e) => [e.arrival, e.departure])).toEqual([
|
||||
['00:00', '00:30'],
|
||||
['02:30', '02:30'],
|
||||
['09:00', '09:00'],
|
||||
['14:00', '14:00'],
|
||||
]);
|
||||
expect(routes.days.some((d) => d.spills?.length)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,11 +169,13 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
(e) => typeof e.lat === 'number' && typeof e.lng === 'number' && Number.isFinite(e.lat) && Number.isFinite(e.lng),
|
||||
)
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0));
|
||||
const from = located.find((e) => e.role === 'from') ?? located[0];
|
||||
// A ride needs both ends to say where the drive stops and where it resumes, and takes
|
||||
// the last located stop for its arrival when no endpoint is marked as one. A hire car's
|
||||
// return is only ever the desk marked as such: guessing one would put a stop on the
|
||||
// road nobody booked. The same desk twice is fine for a car, and no seam for a ride.
|
||||
// the first and the last located stop for them when no endpoint is marked. A hire car's
|
||||
// desks are only ever the ones marked as such: guessing one would put a stop on the
|
||||
// road nobody booked, and a return desk read as the pick-up starts the drive where it
|
||||
// ends. The same desk twice is fine for a car, and no seam for a ride.
|
||||
const from =
|
||||
kind === 'ride' ? (located.find((e) => e.role === 'from') ?? located[0]) : located.find((e) => e.role === 'from');
|
||||
const to =
|
||||
kind === 'ride'
|
||||
? ([...located].reverse().find((e) => e.role === 'to') ?? located[located.length - 1])
|
||||
@@ -225,16 +227,14 @@ export function carrierSeam(booking: CarrierBooking): CarrierSeam | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Minutes of the ride as the clocks tell them, across the days the booking spans.
|
||||
* Minutes of a ride as the clocks tell them, across the days it spans.
|
||||
*
|
||||
* Wall clocks on purpose, not elapsed time: the chain runs on the local clock, and after
|
||||
* landing the day goes on in the destination's time. A ride that lands earlier on the
|
||||
* clock than it left (westward across the date line) cannot be told from a ride of no
|
||||
* length this way and counts as one. Null when either clock is missing.
|
||||
*/
|
||||
export function carrierRideMinutes(seam: CarrierSeam, dayDelta: number): number | null {
|
||||
const dep = parseClock(seam.departure.clock);
|
||||
const arr = parseClock(seam.arrival?.clock);
|
||||
function rideMinutes(dep: number | null, arr: number | null, dayDelta: number): number | null {
|
||||
if (dep === null || arr === null) return null;
|
||||
return Math.max(0, arr + Math.max(0, dayDelta) * 1440 - dep);
|
||||
}
|
||||
@@ -251,11 +251,6 @@ function rolesOf(seam: CarrierSeam): { start: CarrierTerminal['role']; end: Carr
|
||||
return seam.kind === 'rental' ? { start: 'pickup', end: 'return' } : { start: 'departure', end: 'arrival' };
|
||||
}
|
||||
|
||||
/** Whether a stop is a carrier terminal, which is what every writer has to refuse. */
|
||||
export function isCarrierStop(stop: Pick<RoadtripStop, 'carrier'> | null | undefined): boolean {
|
||||
return !!stop?.carrier;
|
||||
}
|
||||
|
||||
function terminalStop(
|
||||
seam: CarrierSeam,
|
||||
role: CarrierTerminal['role'],
|
||||
@@ -269,8 +264,10 @@ function terminalStop(
|
||||
// The departure terminal is pinned a check-in ahead of the timetable and left at the
|
||||
// timetable's minute; the arrival one is pinned at the timetable's minute and left at
|
||||
// once. Neither has a stay of its own. A hire car's desks are pinned at the booking's
|
||||
// own clock, and the drive goes on from them without a stay.
|
||||
const time = role === 'departure' && depart !== null ? formatClock(depart - checkIn) : end.clock;
|
||||
// own clock, and the drive goes on from them without a stay. The pin never goes past
|
||||
// midnight: a red-eye inside its allowance would otherwise read as the evening before,
|
||||
// and the chain would file every stop after it on the next day's card.
|
||||
const pinnedAt = role === 'departure' && depart !== null ? Math.max(0, depart - checkIn) : null;
|
||||
return {
|
||||
carrier: {
|
||||
reservationId: seam.reservationId,
|
||||
@@ -287,9 +284,9 @@ function terminalStop(
|
||||
name: end.name,
|
||||
lat: end.lat,
|
||||
lng: end.lng,
|
||||
time,
|
||||
time: pinnedAt === null ? end.clock : formatClock(pinnedAt),
|
||||
leaveAt: role === 'departure' ? end.clock : null,
|
||||
dwellMinutes: role === 'departure' && depart !== null ? checkIn : 0,
|
||||
dwellMinutes: depart !== null && pinnedAt !== null ? depart - pinnedAt : 0,
|
||||
checkInTime: null,
|
||||
night: false,
|
||||
endDay: false,
|
||||
@@ -458,12 +455,7 @@ export function carrierLegsFor(
|
||||
for (let i = 0; i < run.length - 1; i++) {
|
||||
const from = run[i]!;
|
||||
const to = run[i + 1]!;
|
||||
const dep = parseClock(from.carrier?.at);
|
||||
const arr = parseClock(to.carrier?.at);
|
||||
const minutes =
|
||||
from.carrier && to.carrier && dep !== null && arr !== null
|
||||
? Math.max(0, arr + Math.max(0, dayDelta(from, to)) * 1440 - dep)
|
||||
: null;
|
||||
const minutes = rideMinutes(parseClock(from.carrier?.at), parseClock(to.carrier?.at), dayDelta(from, to));
|
||||
legs[key(from, to)] = carrierLeg(from, to, minutes);
|
||||
}
|
||||
return legs;
|
||||
|
||||
@@ -2,13 +2,18 @@ import {
|
||||
boxAround,
|
||||
corridorTiles,
|
||||
distanceToSegmentKm,
|
||||
drivenPieces,
|
||||
haversineKm,
|
||||
inRiddenRange,
|
||||
pointAtMeters,
|
||||
projectOntoRoute,
|
||||
rideGaps,
|
||||
riddenRanges,
|
||||
sliceAtMeters,
|
||||
simplifyLine,
|
||||
type LatLng,
|
||||
} from './corridor';
|
||||
import type { RoadtripStop } from './planning-types';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -283,3 +288,108 @@ describe('sliceAtMeters', () => {
|
||||
expect(out.every((p) => Number.isFinite(p.lat) && Number.isFinite(p.lng))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a ride in the corridor (#2428)', () => {
|
||||
// A drive north along one meridian, a flight on to the next terminal, and a short drive
|
||||
// after it: a degree of latitude is about 111 km, so every figure reads off the line.
|
||||
const ORIGIN: LatLng = { lat: 52, lng: 13 };
|
||||
const DEPARTURE: LatLng = { lat: 53, lng: 13 };
|
||||
const ARRIVAL: LatLng = { lat: 55, lng: 13 };
|
||||
const HOTEL: LatLng = { lat: 55.5, lng: 13 };
|
||||
const spine = [ORIGIN, DEPARTURE, ARRIVAL, HOTEL];
|
||||
const underTheFlight: LatLng = { lat: 54, lng: 13 };
|
||||
|
||||
const terminal = (
|
||||
role: 'departure' | 'arrival' | 'pickup' | 'return',
|
||||
at: LatLng,
|
||||
reservationId = 7,
|
||||
): Pick<RoadtripStop, 'carrier' | 'lat' | 'lng'> => ({
|
||||
carrier: {
|
||||
reservationId,
|
||||
type: role === 'pickup' || role === 'return' ? 'car' : 'flight',
|
||||
role,
|
||||
title: '',
|
||||
code: null,
|
||||
at: null,
|
||||
},
|
||||
...at,
|
||||
});
|
||||
|
||||
it('a departure followed by the arrival of its own booking is a gap, at the departure index', () => {
|
||||
const stops = [ORIGIN, terminal('departure', DEPARTURE), terminal('arrival', ARRIVAL), HOTEL];
|
||||
expect(rideGaps(stops)).toEqual([{ index: 1, from: DEPARTURE, to: ARRIVAL }]);
|
||||
});
|
||||
|
||||
it('a lone departure, another booking’s arrival and a hire car’s desks make no gap', () => {
|
||||
// Landing tomorrow, the arrival is on another day; the drive between a return desk
|
||||
// and a pick-up desk IS driven, whatever booking the two belong to.
|
||||
expect(rideGaps([ORIGIN, terminal('departure', DEPARTURE)])).toEqual([]);
|
||||
expect(rideGaps([terminal('departure', DEPARTURE, 7), terminal('arrival', ARRIVAL, 8)])).toEqual([]);
|
||||
expect(rideGaps([terminal('pickup', DEPARTURE), terminal('return', ARRIVAL)])).toEqual([]);
|
||||
expect(rideGaps([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('projects both terminals onto the spine and keeps the stretches in order', () => {
|
||||
const [ride] = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]);
|
||||
expect(ride!.fromKm).toBeCloseTo(111, -1);
|
||||
expect(ride!.toKm).toBeCloseTo(333, -1);
|
||||
// Handed in backwards, or twice over, the stretches still read from the start.
|
||||
const two = riddenRanges(spine, [
|
||||
{ from: ARRIVAL, to: HOTEL },
|
||||
{ from: ARRIVAL, to: DEPARTURE },
|
||||
]);
|
||||
expect(two.map((r) => Math.round(r.fromKm))).toEqual([111, 334]);
|
||||
// A ride the spine cannot place, or whose ends land on the same point, is no stretch.
|
||||
expect(riddenRanges([ORIGIN], [{ from: DEPARTURE, to: ARRIVAL }])).toEqual([]);
|
||||
expect(riddenRanges(spine, [{ from: DEPARTURE, to: DEPARTURE }])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a hit under the flight is inside the ride, the terminals themselves are not', () => {
|
||||
const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]);
|
||||
const along = (p: LatLng): number => projectOntoRoute(p, spine)!.alongKm;
|
||||
expect(inRiddenRange(ridden, along(underTheFlight))).toBe(true);
|
||||
expect(inRiddenRange(ridden, along(DEPARTURE))).toBe(false);
|
||||
expect(inRiddenRange(ridden, along(ARRIVAL))).toBe(false);
|
||||
expect(inRiddenRange(ridden, along(ORIGIN))).toBe(false);
|
||||
expect(inRiddenRange([], along(underTheFlight))).toBe(false);
|
||||
});
|
||||
|
||||
it('cuts the line into the driven pieces, and no box is asked for under the flight', () => {
|
||||
const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]);
|
||||
const pieces = drivenPieces(spine, ridden);
|
||||
expect(pieces).toHaveLength(2);
|
||||
expect(pieces[0]![0]).toEqual(ORIGIN);
|
||||
expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(DEPARTURE.lat, 3);
|
||||
expect(pieces[1]![0]!.lat).toBeCloseTo(ARRIVAL.lat, 3);
|
||||
expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL);
|
||||
|
||||
const covers = (tiles: ReturnType<typeof corridorTiles>, p: LatLng): boolean =>
|
||||
tiles.some((t) => t.south <= p.lat && t.north >= p.lat && t.west <= p.lng && t.east >= p.lng);
|
||||
const tiles = pieces.flatMap((piece) => corridorTiles(piece, 5));
|
||||
expect(covers(tiles, underTheFlight)).toBe(false);
|
||||
expect(covers(tiles, { lat: 52.5, lng: 13 })).toBe(true);
|
||||
// The same line straight through is what the search asked for before.
|
||||
expect(covers(corridorTiles(spine, 5), underTheFlight)).toBe(true);
|
||||
});
|
||||
|
||||
it('without a ride the whole line is the one piece, and a window is a slice of it', () => {
|
||||
expect(drivenPieces(spine, [])).toEqual([spine]);
|
||||
expect(drivenPieces(spine, [])[0]).toBe(spine);
|
||||
const [windowed] = drivenPieces(spine, [], { fromKm: 0, toKm: 50 });
|
||||
expect(windowed![windowed!.length - 1]!.lat).toBeCloseTo(52.45, 1);
|
||||
expect(drivenPieces([ORIGIN], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('a window and a ride together leave the driven part of the window only', () => {
|
||||
const ridden = riddenRanges(spine, [{ from: DEPARTURE, to: ARRIVAL }]);
|
||||
const pieces = drivenPieces(spine, ridden, { fromKm: 100, toKm: 400 });
|
||||
expect(pieces).toHaveLength(2);
|
||||
expect(pieces[0]![0]!.lat).toBeCloseTo(52.9, 1);
|
||||
expect(pieces[0]![pieces[0]!.length - 1]!.lat).toBeCloseTo(53, 2);
|
||||
expect(pieces[1]![0]!.lat).toBeCloseTo(55, 2);
|
||||
expect(pieces[1]![pieces[1]!.length - 1]).toEqual(HOTEL);
|
||||
// A window that ends before the ride never reaches it; one lying inside it is empty.
|
||||
expect(drivenPieces(spine, ridden, { fromKm: 0, toKm: 50 })).toHaveLength(1);
|
||||
expect(drivenPieces(spine, ridden, { fromKm: 150, toKm: 300 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RoadtripStop } from './planning-types';
|
||||
|
||||
export interface LatLng {
|
||||
lat: number;
|
||||
lng: number;
|
||||
@@ -224,3 +226,83 @@ export function boxAround(point: LatLng, radiusKm: number): Bbox {
|
||||
east: point.lng + dLng,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stretch of the spine, in kilometres along it. */
|
||||
export interface KmRange {
|
||||
fromKm: number;
|
||||
toKm: number;
|
||||
}
|
||||
|
||||
/** A ride among a day's stops: the departure terminal, at `index`, and the arrival it jumps to. */
|
||||
export interface RideGap {
|
||||
index: number;
|
||||
from: LatLng;
|
||||
to: LatLng;
|
||||
}
|
||||
|
||||
/**
|
||||
* The rides on a day (#2428): each departure terminal followed by the arrival of the same
|
||||
* booking. The line runs straight from the one to the other there, and the car is not on
|
||||
* it, so a corridor search leaves the stretch out and drops what it finds under it.
|
||||
*/
|
||||
export function rideGaps(stops: readonly Pick<RoadtripStop, 'carrier' | 'lat' | 'lng'>[]): RideGap[] {
|
||||
const out: RideGap[] = [];
|
||||
stops.forEach((stop, i) => {
|
||||
const next = stops[i + 1];
|
||||
if (stop.carrier?.role === 'departure' && next?.carrier?.reservationId === stop.carrier.reservationId) {
|
||||
out.push({ index: i, from: { lat: stop.lat, lng: stop.lng }, to: { lat: next.lat, lng: next.lng } });
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ridden stretches as kilometres along the spine, in order. A ride whose two ends
|
||||
* land on the same point of the spine, or that has no spine to be measured against, is
|
||||
* no stretch. A terminal well off the line still projects onto its nearest point, so a
|
||||
* ride is never dropped for standing away from the road.
|
||||
*/
|
||||
export function riddenRanges(spine: LatLng[], gaps: readonly { from: LatLng; to: LatLng }[]): KmRange[] {
|
||||
const out: KmRange[] = [];
|
||||
for (const gap of gaps) {
|
||||
const a = projectOntoRoute(gap.from, spine)?.alongKm;
|
||||
const b = projectOntoRoute(gap.to, spine)?.alongKm;
|
||||
if (a === undefined || b === undefined) continue;
|
||||
const range = { fromKm: Math.min(a, b), toKm: Math.max(a, b) };
|
||||
if (range.toKm > range.fromKm) out.push(range);
|
||||
}
|
||||
return out.sort((a, b) => a.fromKm - b.fromKm);
|
||||
}
|
||||
|
||||
// The terminals themselves stand at the ends of a ride and stay on the road.
|
||||
const TERMINAL_SLACK_KM = 0.05;
|
||||
|
||||
export function inRiddenRange(ridden: readonly KmRange[], alongKm: number): boolean {
|
||||
return ridden.some((r) => alongKm > r.fromKm + TERMINAL_SLACK_KM && alongKm < r.toKm - TERMINAL_SLACK_KM);
|
||||
}
|
||||
|
||||
/**
|
||||
* The spine cut into the pieces the car drives: the window, or the whole line, with every
|
||||
* ridden stretch left out. Only the pieces to tile are cut, never the spine itself: every
|
||||
* alongKm is a distance along the spine, and cutting that would renumber every hit and
|
||||
* every stop the moment a window moved.
|
||||
*/
|
||||
export function drivenPieces(spine: LatLng[], ridden: readonly KmRange[], window?: KmRange | null): LatLng[][] {
|
||||
const start = window?.fromKm ?? 0;
|
||||
const end = window?.toKm ?? Number.POSITIVE_INFINITY;
|
||||
const slice = (fromKm: number, toKm: number): LatLng[] =>
|
||||
sliceAtMeters(spine, fromKm * 1000, Number.isFinite(toKm) ? toKm * 1000 : Number.MAX_SAFE_INTEGER);
|
||||
const pieces: LatLng[][] = [];
|
||||
if (!ridden.length) {
|
||||
pieces.push(window ? slice(start, end) : spine);
|
||||
} else {
|
||||
let cursor = start;
|
||||
for (const range of ridden) {
|
||||
if (range.fromKm >= end) break;
|
||||
if (range.fromKm > cursor) pieces.push(slice(cursor, range.fromKm));
|
||||
cursor = Math.max(cursor, range.toKm);
|
||||
}
|
||||
if (cursor < end) pieces.push(slice(cursor, end));
|
||||
}
|
||||
return pieces.filter((piece) => piece.length > 1);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user