feat: improve routing UI

This commit is contained in:
Cfp
2025-06-22 11:25:33 +02:00
parent ed425faf1a
commit 02c019ba93
9 changed files with 327 additions and 98 deletions

View File

@ -16,9 +16,8 @@
}); });
</script> </script>
{#if !routing.currentTrip} {#if routing.currentTrip}
<Sidebar></Sidebar>
{:else}
<RoutingInfo /> <RoutingInfo />
{/if} {/if}
<Sidebar></Sidebar>
<Map></Map> <Map></Map>

View File

@ -12,7 +12,7 @@
img { img {
/* the images are black, recolor them white */ /* the images are black, recolor them white */
filter: invert(1); filter: invert(1);
width: 24px; width: 48px;
height: 24px; height: 48px;
} }
</style> </style>

View File

@ -1,13 +1,97 @@
<script> <script lang="ts">
import LanesDisplay from "$lib/services/navigation/LanesDisplay.svelte"; import LanesDisplay from "$lib/services/navigation/LanesDisplay.svelte";
import { routing } from "$lib/services/navigation/routing.svelte"; import { decodePolyline, routing } from "$lib/services/navigation/routing.svelte";
import { location } from "./location.svelte";
import ManeuverIcon from "./ManeuverIcon.svelte"; import ManeuverIcon from "./ManeuverIcon.svelte";
// Helper: Haversine distance in meters
function haversine(a: { lat: number; lon: number }, b: { lat: number; lon: number }) {
const R = 6371000;
const toRad = (d: number) => d * Math.PI / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const aVal = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(aVal), Math.sqrt(1 - aVal));
}
// Helper: Project point onto segment AB, return projected point and distance along segment
function projectPointToSegment(p: WorldLocation, a: WorldLocation, b: WorldLocation) {
const toRad = (deg: number) => deg * Math.PI / 180;
const toDeg = (rad: number) => rad * 180 / Math.PI;
const lat1 = toRad(a.lat), lon1 = toRad(a.lon);
const lat2 = toRad(b.lat), lon2 = toRad(b.lon);
const lat3 = toRad(p.lat), lon3 = toRad(p.lon);
const dLon = lon2 - lon1;
const dLat = lat2 - lat1;
const t = ((lat3 - lat1) * dLat + (lon3 - lon1) * dLon) /
(dLat * dLat + dLon * dLon);
// Clamp to [0,1]
const clampedT = Math.max(0, Math.min(1, t));
const latProj = lat1 + clampedT * dLat;
const lonProj = lon1 + clampedT * dLon;
return {
lat: toDeg(latProj),
lon: toDeg(lonProj),
t: clampedT,
distToUser: haversine(p, { lat: toDeg(latProj), lon: toDeg(lonProj) }),
};
}
// const point = $derived(decodePolyline(routing.currentTrip?.legs[0].shape || "")[routing.currentTripInfo.currentManeuver?.end_shape_index || 0]);
// const distance = $derived(Math.sqrt(Math.pow(point.lat - location.lat, 2) + Math.pow(point.lon - location.lng, 2)) * 111000); // Approximate conversion to meters
const shape = $derived(decodePolyline(routing.currentTrip?.legs[0].shape || ""));
const maneuver = $derived(routing.currentTripInfo.currentManeuver);
const distance = $derived.by(() => {
const lat = location.lat;
const lon = location.lng;
if (!shape || shape.length === 0 || !maneuver) return 0;
const start = shape[maneuver.begin_shape_index];
const end = shape[maneuver.end_shape_index];
if (!start || !end) return 0;
const projected = projectPointToSegment({ lat, lon }, start, end);
return projected.distToUser;
});
const roundDistance = $derived.by(() => {
const dist = Math.round(distance);
if (dist < 100) {
return Math.round(dist / 5) * 5;
} else if (dist < 1000) {
return Math.round(dist / 50) * 50;
} else if (dist < 10000) {
return Math.round(dist / 100) * 100;
} else if (dist < 100000) {
return Math.round(dist / 1000) * 1000;
} else {
return Math.round(dist / 5000) * 5000;
}
})
const distanceText = $derived.by(() => {
const dist = roundDistance;
if (dist < 1000) return `${dist} m`;
return `${(dist / 1000)} km`;
})
</script> </script>
<div class="fixed top-4 left-4 z-50 w-3/4 h-10 bg-background text-white"> <div class="fixed top-4 left-4 z-50 w-[calc(100%-32px)] bg-background/60 text-white rounded-lg overflow-hidden" style="backdrop-filter: blur(5px);">
<div class="flex gap-2"> <div class="p-2 flex gap-2">
<ManeuverIcon maneuver={routing.currentTripInfo.currentManeuver?.type ?? 0} /> <ManeuverIcon maneuver={routing.currentTripInfo.currentManeuver?.type ?? 0} />
{routing.currentTripInfo.currentManeuver?.instruction} <div class="flex gap-1 flex-col">
<span class="text-xl font-bold">{distanceText}</span>
<span>{routing.currentTripInfo.currentManeuver?.instruction}</span>
</div>
</div> </div>
<LanesDisplay <LanesDisplay
lanes={routing.currentTripInfo.currentManeuver?.lanes} lanes={routing.currentTripInfo.currentManeuver?.lanes}

View File

@ -14,6 +14,8 @@
import SearchSidebar from "./sidebar/SearchSidebar.svelte"; import SearchSidebar from "./sidebar/SearchSidebar.svelte";
import { advertiseRemoteLocation, location, remoteLocation } from "./location.svelte"; import { advertiseRemoteLocation, location, remoteLocation } from "./location.svelte";
import * as Popover from "../ui/popover"; import * as Popover from "../ui/popover";
import { routing } from "$lib/services/navigation/routing.svelte";
import InRouteSidebar from "./sidebar/InRouteSidebar.svelte";
const views: {[key: string]: Component<any>} = { const views: {[key: string]: Component<any>} = {
main: MainSidebar, main: MainSidebar,
@ -46,6 +48,7 @@
let searchText = $derived.by(debounce(() => searchbar.text, 300)); let searchText = $derived.by(debounce(() => searchbar.text, 300));
let searchResults: Feature[] = $state([]); let searchResults: Feature[] = $state([]);
let mobileView = $derived(window.innerWidth < 768 || routing.currentTrip);
$effect(() => { $effect(() => {
if(!searchText) { if(!searchText) {
@ -69,12 +72,14 @@
}); });
</script> </script>
<div id="floating-search"> {#if !routing.currentTrip}
<Input class="h-10" <div id="floating-search" class={mobileView ? "mobileView" : ""}>
placeholder="Search..." bind:value={searchbar.text} /> <Input class="h-10"
</div> placeholder="Search..." bind:value={searchbar.text} />
<div id="sidebar" style={window.innerWidth < 768 ? `height: ${sidebarHeight}px` : ""}> </div>
{#if window.innerWidth < 768} {/if}
<div id="sidebar" class={mobileView ? "mobileView" : ""} style={(mobileView ? `height: ${sidebarHeight}px;` : "") + (routing.currentTrip ? "bottom: 0 !important;" : "")}>
{#if mobileView}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_interactive_supports_focus --> <!-- svelte-ignore a11y_interactive_supports_focus -->
<div role="button" id="grabber" style="height: 10px; cursor: grab; display: flex; justify-content: center; align-items: center; touch-action: none;" ontouchstart={(e) => { <div role="button" id="grabber" style="height: 10px; cursor: grab; display: flex; justify-content: center; align-items: center; touch-action: none;" ontouchstart={(e) => {
@ -102,53 +107,64 @@
<div style="height: 8px; background-color: #acacac; width: 40%; border-radius: 15px;"></div> <div style="height: 8px; background-color: #acacac; width: 40%; border-radius: 15px;"></div>
</div> </div>
{/if} {/if}
<CurrentView {...view.current.props}></CurrentView>
{#if routing.currentTrip}
<InRouteSidebar />
{:else}
<CurrentView {...view.current.props}></CurrentView>
{/if}
</div> </div>
<div id="navigation"> {#if !routing.currentTrip}
<button> <div id="navigation" class={mobileView ? "mobileView" : ""}>
<HomeIcon /> <button onclick={() => view.switch("main")}>
</button> <HomeIcon />
<button> </button>
<UserIcon /> <RequiresCapability capability="auth">
</button> <button onclick={async () => {
<button> view.switch("user");
<SettingsIcon /> }}>
</button> <UserIcon />
<!-- <button onclick={() => {
location.toggleLock();
}}>
L
</button> -->
<Popover.Root>
<Popover.Trigger>
<button>
<EllipsisIcon />
</button> </button>
</Popover.Trigger> </RequiresCapability>
<Popover.Content> <button>
<div class="flex flex-col gap-2"> <SettingsIcon />
<Button variant="outline" onclick={() => { </button>
location.toggleLock(); <!-- <button onclick={() => {
}}> location.toggleLock();
{location.locked ? "Unlock Location" : "Lock Location"} }}>
</Button> L
{#if location.code} </button> -->
<span>Advertise code: {location.code}</span> <Popover.Root>
{/if} <Popover.Trigger>
<Button variant="outline" onclick={() => { <button>
advertiseRemoteLocation(); <EllipsisIcon />
}}> </button>
Advertise Location </Popover.Trigger>
</Button> <Popover.Content>
<Button variant="outline" onclick={() => { <div class="flex flex-col gap-2">
remoteLocation(prompt("Code?") || ""); <Button variant="outline" onclick={() => {
}}> location.toggleLock();
Join Remote Location }}>
</Button> {location.locked ? "Unlock Location" : "Lock Location"}
</div> </Button>
</Popover.Content> {#if location.code}
</Popover.Root> <span>Advertise code: {location.code}</span>
</div> {/if}
<Button variant="outline" onclick={() => {
advertiseRemoteLocation();
}}>
Advertise Location
</Button>
<Button variant="outline" onclick={() => {
remoteLocation(prompt("Code?") || "");
}}>
Join Remote Location
</Button>
</div>
</Popover.Content>
</Popover.Root>
</div>
{/if}
<style> <style>
#sidebar { #sidebar {
@ -205,36 +221,35 @@
justify-content: space-around; justify-content: space-around;
} }
@media (max-width: 768px) { /* mobile view */
#sidebar { #sidebar.mobileView {
position: fixed; position: fixed;
top: unset; top: unset;
bottom: 50px; bottom: 50px;
left: 0; left: 0;
/* min-width: calc(100% - 20px); /* min-width: calc(100% - 20px);
max-width: calc(100% - 20px); */ max-width: calc(100% - 20px); */
min-width: calc(100%); min-width: calc(100%);
max-width: calc(100%); max-width: calc(100%);
width: calc(100% - 20px); width: calc(100% - 20px);
height: 200px; height: 200px;
margin: unset; margin: unset;
/* margin-left: 10px; /* margin-left: 10px;
margin-right: 10px; */ margin-right: 10px; */
border-radius: 0; border-radius: 0;
border-top-left-radius: 15px; border-top-left-radius: 15px;
border-top-right-radius: 15px; border-top-right-radius: 15px;
padding-top: 5px; /* for the grabber */ padding-top: 5px; /* for the grabber */
} }
#floating-search { #floating-search.mobileView {
width: calc(100% - 20px); width: calc(100% - 20px);
} }
#navigation { #navigation.mobileView {
margin: 0; margin: 0;
width: calc(100%); width: calc(100%);
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
}
} }
</style> </style>

View File

@ -1,3 +1,4 @@
import { routing } from "$lib/services/navigation/routing.svelte";
import { reverseGeocode } from "$lib/services/Search"; import { reverseGeocode } from "$lib/services/Search";
import { view } from "./sidebar.svelte"; import { view } from "./sidebar.svelte";
@ -18,12 +19,12 @@ export const map = $state({
return; return;
} }
console.log("Updating map padding"); console.log("Updating map padding");
if (window.innerWidth < 768) { if (window.innerWidth < 768 || routing.currentTrip) {
const calculatedSidebarHeight = document.querySelector<HTMLDivElement>("#sidebar")!.getBoundingClientRect().height; const calculatedSidebarHeight = document.querySelector<HTMLDivElement>("#sidebar")!.getBoundingClientRect().height;
map._setPadding({ map._setPadding({
top: 50, top: routing.currentTrip ? 64 : 0,
right: 0, right: 0,
bottom: calculatedSidebarHeight + 50, bottom: calculatedSidebarHeight,
left: 0 left: 0
}); });
return; return;

View File

@ -0,0 +1,130 @@
<script lang="ts">
import { Button } from "$lib/components/ui/button";
import { decodePolyline, routing, stopNavigation } from "$lib/services/navigation/routing.svelte";
import { advertiseRemoteLocation, location } from "../location.svelte";
// Helper: Haversine distance in meters
function haversine(a: { lat: number; lon: number }, b: { lat: number; lon: number }) {
const R = 6371000;
const toRad = (d: number) => d * Math.PI / 180;
const dLat = toRad(b.lat - a.lat);
const dLon = toRad(b.lon - a.lon);
const lat1 = toRad(a.lat);
const lat2 = toRad(b.lat);
const aVal = Math.sin(dLat / 2) ** 2 +
Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.atan2(Math.sqrt(aVal), Math.sqrt(1 - aVal));
}
// Helper: Project point onto segment AB, return projected point and distance along segment
function projectPointToSegment(p: WorldLocation, a: WorldLocation, b: WorldLocation) {
const toRad = (deg: number) => deg * Math.PI / 180;
const toDeg = (rad: number) => rad * 180 / Math.PI;
const lat1 = toRad(a.lat), lon1 = toRad(a.lon);
const lat2 = toRad(b.lat), lon2 = toRad(b.lon);
const lat3 = toRad(p.lat), lon3 = toRad(p.lon);
const dLon = lon2 - lon1;
const dLat = lat2 - lat1;
const t = ((lat3 - lat1) * dLat + (lon3 - lon1) * dLon) /
(dLat * dLat + dLon * dLon);
// Clamp to [0,1]
const clampedT = Math.max(0, Math.min(1, t));
const latProj = lat1 + clampedT * dLat;
const lonProj = lon1 + clampedT * dLon;
return {
lat: toDeg(latProj),
lon: toDeg(lonProj),
t: clampedT,
distToUser: haversine(p, { lat: toDeg(latProj), lon: toDeg(lonProj) }),
};
}
const shape = $derived(decodePolyline(routing.currentTrip?.legs[0].shape || ""));
const maneuver = $derived(routing.currentTripInfo.currentManeuver);
const fullDistance = $derived.by(() => {
const lat = location.lat;
const lon = location.lng;
if (!shape.length) return 0;
// 1⃣ find projection onto any segment of the full shape
let best = { idx: 0, proj: shape[0], dist: Infinity };
for (let i = 0; i < shape.length - 1; i++) {
const a = shape[i];
const b = shape[i + 1];
const proj = projectPointToSegment({ lat, lon }, a, b);
if (proj.distToUser < best.dist) {
best = { idx: i, proj: { lat: proj.lat, lon: proj.lon }, dist: proj.distToUser };
}
}
// 2⃣ sum from the projection point to the very last point
let total = 0;
// from projection → next vertex
total += haversine(best.proj, shape[best.idx + 1]);
// then each remaining segment
for (let j = best.idx + 1; j < shape.length - 1; j++) {
total += haversine(shape[j], shape[j + 1]);
}
return total;
});
const roundFullDistance = $derived.by(() => {
const dist = Math.round(fullDistance);
if (dist < 100) {
return Math.round(dist / 5) * 5;
} else if (dist < 1000) {
return Math.round(dist / 50) * 50;
} else if (dist < 10000) {
return Math.round(dist / 100) * 100;
} else if (dist < 100000) {
return Math.round(dist / 1000) * 1000;
} else {
return Math.round(dist / 5000) * 5000;
}
})
const fullDistanceText = $derived.by(() => {
const dist = roundFullDistance;
if (dist < 1000) return `${dist} m`;
return `${(dist / 1000)} km`;
})
</script>
{fullDistanceText} left
<Button onclick={() => {
location.toggleLock();
}}>LOCK</Button>
<Button onclick={() => {
stopNavigation();
}}>End Trip</Button>
<div class="flex flex-col gap-2 mt-5">
{#if location.code}
<span>Share Code: {location.code}</span>
<Button variant="secondary" onclick={() => {
location.advertiser?.close();
location.advertiser = null;
location.code = null;
}}>
Stop Sharing Location
</Button>
{:else}
<Button variant="secondary" onclick={() => {
advertiseRemoteLocation();
}}>
Share Trip Status & Location
</Button>
{/if}
</div>

View File

@ -51,19 +51,19 @@
} }
:global(.lane-image > span) { :global(.lane-image > span) {
font-size: 2rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
color: #812020; color: #812020;
} }
:global(.lane-image.active > span) { :global(.lane-image.active > span) {
font-size: 2rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
color: #ff0000; color: #ff0000;
} }
:global(.lane-image.valid > span) { :global(.lane-image.valid > span) {
font-size: 2rem; font-size: 1rem;
font-weight: bold; font-weight: bold;
color: #cc2c2c; color: #cc2c2c;
} }

View File

@ -14,7 +14,7 @@
<style> <style>
#lanes { #lanes {
background-color: #1a1a1a; background-color: hsl(0, 0%, 10%, 0.6);
padding: 10px; padding: 10px;
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;

View File

@ -64,7 +64,7 @@ function geometryToGeoJSON(polyline: WorldLocation[]): GeoJSON.Feature {
}; };
} }
function decodePolyline(encoded: string): WorldLocation[] { export function decodePolyline(encoded: string): WorldLocation[] {
let points = []; let points = [];
let index = 0; let index = 0;
let len = encoded.length; let len = encoded.length;