feat: improve routing UI
This commit is contained in:
@ -16,9 +16,8 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !routing.currentTrip}
|
||||
<Sidebar></Sidebar>
|
||||
{:else}
|
||||
{#if routing.currentTrip}
|
||||
<RoutingInfo />
|
||||
{/if}
|
||||
<Sidebar></Sidebar>
|
||||
<Map></Map>
|
||||
@ -12,7 +12,7 @@
|
||||
img {
|
||||
/* the images are black, recolor them white */
|
||||
filter: invert(1);
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
@ -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;
|
||||
|
||||
130
src/lib/components/lnv/sidebar/InRouteSidebar.svelte
Normal file
130
src/lib/components/lnv/sidebar/InRouteSidebar.svelte
Normal 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>
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user