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>
{#if !routing.currentTrip}
<Sidebar></Sidebar>
{:else}
{#if routing.currentTrip}
<RoutingInfo />
{/if}
<Sidebar></Sidebar>
<Map></Map>

View File

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

View File

@ -1,13 +1,97 @@
<script>
<script lang="ts">
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";
// 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>
<div class="fixed top-4 left-4 z-50 w-3/4 h-10 bg-background text-white">
<div class="flex gap-2">
<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="p-2 flex gap-2">
<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>
<LanesDisplay
lanes={routing.currentTripInfo.currentManeuver?.lanes}

View File

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

View File

@ -1,3 +1,4 @@
import { routing } from "$lib/services/navigation/routing.svelte";
import { reverseGeocode } from "$lib/services/Search";
import { view } from "./sidebar.svelte";
@ -18,12 +19,12 @@ export const map = $state({
return;
}
console.log("Updating map padding");
if (window.innerWidth < 768) {
if (window.innerWidth < 768 || routing.currentTrip) {
const calculatedSidebarHeight = document.querySelector<HTMLDivElement>("#sidebar")!.getBoundingClientRect().height;
map._setPadding({
top: 50,
top: routing.currentTrip ? 64 : 0,
right: 0,
bottom: calculatedSidebarHeight + 50,
bottom: calculatedSidebarHeight,
left: 0
});
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) {
font-size: 2rem;
font-size: 1rem;
font-weight: bold;
color: #812020;
}
:global(.lane-image.active > span) {
font-size: 2rem;
font-size: 1rem;
font-weight: bold;
color: #ff0000;
}
:global(.lane-image.valid > span) {
font-size: 2rem;
font-size: 1rem;
font-weight: bold;
color: #cc2c2c;
}

View File

@ -14,7 +14,7 @@
<style>
#lanes {
background-color: #1a1a1a;
background-color: hsl(0, 0%, 10%, 0.6);
padding: 10px;
display: flex;
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 index = 0;
let len = encoded.length;