Integrate browser geolocation API (#9)

* Add GPS service and nautical base city data

- Implement `GpsService` with methods for position updates and enabling/disabling GPS.
- Introduce test data for nautical base cities with key attributes like population, coordinates, and images.
- Update dependencies in `bun.lock` with required packages such as `geojson`.

* give map a custom style

* shift towards rust exclusivity

* `build.rs` streamlines map build. Added an axum server with the map assets embedded.

* update readmes

* base-map api retrieves geolocation from the navigator of the browser

* make map standalone wry that pulls assets from the server to simulate behavior in bevy

* wip wasm

* wasm build fixed

* fix path ref to assets

---------

Co-authored-by: geoffsee <>
This commit is contained in:
Geoff Seemueller
2025-07-16 17:44:25 -04:00
committed by GitHub
parent 4b3dd2a1c3
commit 602bc5d4b8
49 changed files with 7748 additions and 1279 deletions

View File

@@ -0,0 +1,73 @@
use tao::{
event::{Event, WindowEvent},
event_loop::{ControlFlow, EventLoop},
window::WindowBuilder,
};
use tao::platform::macos::WindowBuilderExtMacOS;
use tower_http::follow_redirect::policy::PolicyExt;
use tower_http::ServiceExt;
use wry::WebViewBuilder;
fn main() -> wry::Result<()> {
let event_loop = EventLoop::new();
let window = WindowBuilder::new()
.with_title("YachtPit Map")
.build(&event_loop).unwrap();
let builder = WebViewBuilder::new()
.with_url("http://localhost:8080/geolocate")
.with_new_window_req_handler(|e| {
println!("NewWindow: {e:?}");
true
})
.with_ipc_handler(|e| {
println!("IPC: {e:?}");
})
.with_drag_drop_handler(|e| {
match e {
wry::DragDropEvent::Enter { paths, position } => {
println!("DragEnter: {position:?} {paths:?} ")
}
wry::DragDropEvent::Over { position } => println!("DragOver: {position:?} "),
wry::DragDropEvent::Drop { paths, position } => {
println!("DragDrop: {position:?} {paths:?} ")
}
wry::DragDropEvent::Leave => println!("DragLeave"),
_ => {}
}
true
});
#[cfg(any(
target_os = "windows",
target_os = "macos",
target_os = "ios",
target_os = "android"
))]
let _webview = builder.build(&window)?;
#[cfg(not(any(
target_os = "windows",
target_os = "macos",
target_os = "ios",
target_os = "android"
)))]
let _webview = {
use tao::platform::unix::WindowExtUnix;
use wry::WebViewBuilderExtUnix;
let vbox = window.default_vbox().unwrap();
builder.build_gtk(vbox)?
};
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
if let Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} = event
{
*control_flow = ControlFlow::Exit;
}
});
}

View File

@@ -0,0 +1,216 @@
use axum::response::{Html, IntoResponse};
pub async fn geolocate() -> impl IntoResponse {
Html(
r#"
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Geo Demo</title></head>
<body>
<div style="font-family: Arial, sans-serif; padding: 20px;">
<h2>Location Service</h2>
<div id="status"></div>
<pre id="out"></pre>
</div>
<script type="module">
const out = document.getElementById('out');
const status = document.getElementById('status');
// Persist / reuse a perbrowser UUID
let id = localStorage.getItem('browser_id');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('browser_id', id);
}
async function checkLocationPermission() {
if (!navigator.geolocation) {
status.innerHTML = '<p style="color: red;">Geolocation is not supported by this browser.</p>';
return false;
}
if (!navigator.permissions) {
// Fallback for browsers without Permissions API
return requestLocationDirectly();
}
try {
const permission = await navigator.permissions.query({name: 'geolocation'});
switch(permission.state) {
case 'granted':
status.innerHTML = '<p style="color: green;">Location permission granted. Getting location...</p>';
return getCurrentLocation();
case 'denied':
status.innerHTML = '<p style="color: red;">Location permission denied. Please enable location access in your browser settings and refresh the page.</p>';
return false;
case 'prompt':
status.innerHTML = '<p style="color: orange;">Requesting location permission...</p>';
return requestLocationDirectly();
default:
return requestLocationDirectly();
}
} catch (error) {
console.error('Error checking permission:', error);
return requestLocationDirectly();
}
}
function requestLocationDirectly() {
status.innerHTML = '<p>Requesting location access...</p>';
return getCurrentLocation();
}
function getCurrentLocation() {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
async pos => {
const payload = {
id,
lat: pos.coords.latitude,
lon: pos.coords.longitude,
accuracy: pos.coords.accuracy,
timestamp: pos.timestamp
};
out.textContent = JSON.stringify(payload, null, 2);
status.innerHTML = '<p style="color: green;">Location obtained successfully!</p>';
try {
await fetch('/geolocate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
status.innerHTML += '<p style="color: green;">Location sent to server.</p>';
} catch (fetchError) {
status.innerHTML += `<p style="color: orange;">Warning: Could not send location to server: ${fetchError.message}</p>`;
}
resolve(true);
},
err => {
handleLocationError(err);
reject(err);
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 60000
}
);
});
}
function handleLocationError(err) {
let errorMessage = '';
let color = 'red';
switch(err.code) {
case err.PERMISSION_DENIED:
errorMessage = 'Location access denied. Please enable location access in your browser settings and refresh the page.';
break;
case err.POSITION_UNAVAILABLE:
errorMessage = 'Location information is unavailable. Please check your GPS/location services.';
color = 'orange';
break;
case err.TIMEOUT:
errorMessage = 'Location request timed out. Please refresh the page to try again.';
color = 'orange';
break;
default:
errorMessage = `Unknown error occurred: ${err.message}`;
break;
}
status.innerHTML = `<p style="color: ${color};">Error: ${errorMessage}</p>`;
out.textContent = `Error: ${errorMessage}`;
}
// Start the location check when page loads
checkLocationPermission();
</script>
</body>
</html>
"#,
)
}
// v2
// pub async fn geolocate() -> impl IntoResponse {
// Html(
// r#"
// <!doctype html>
// <html lang="en">
// <head><meta charset="utf-8"><title>Geo Demo</title></head>
// <body>
// <pre id="out"></pre>
//
// <script type="module">
// let position_var = undefined;
// const out = document.getElementById('out');
//
// // Persist / reuse a perbrowser UUID
// let id = localStorage.getItem('browser_id');
// if (!id) {
// id = crypto.randomUUID();
// localStorage.setItem('browser_id', id);
// }
//
// if (!navigator.geolocation) {
// out.textContent = 'Geolocation not supported';
// } else {
//
// navigator.geolocation.getCurrentPosition(
// async pos => {
// const payload = {
// id,
// lat: pos.coords.latitude,
// lon: pos.coords.longitude
// };
// position_var = JSON.stringify(payload, null, 2);
// out.textContent = JSON.stringify(payload, null, 2);
// await fetch('/geolocate', { // <-- new route
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify(payload)
// });
// },
// err => out.textContent = `Error: ${err.message}`
// );
// }
// </script>
// </body>
// </html>
// "#,
// )
// }
// v1
// pub async fn geolocate() -> impl IntoResponse {
// // A minimal page that asks only after the user clicks.
// Html(r#"
// <!doctype html>
// <html lang="en">
// <head><meta charset="utf-8"><title>Geo Demo</title></head>
// <body>
// <pre id="out"></pre>
//
// <script>
// console.log('Hello from the browser');
// const out = document.getElementById('out');
// if (!navigator.geolocation) {
// out.textContent = 'Geolocation not supported';
// } else {
// navigator.geolocation.getCurrentPosition(
// pos => out.textContent =
// `Lat${pos.coords.latitude}, Lon${pos.coords.longitude}`,
// err => out.textContent = `Error: ${err.message}`
// );
// }
// </script>
// </body>
// </html>
// "#)
// }

View File

@@ -0,0 +1,33 @@
mod geolocate;
mod app;
use axum::response::IntoResponse;
use axum::routing::post;
// src/lib.rs
use axum::{routing::get, Json, Router};
use serde::Deserialize;
use tower_http::trace::TraceLayer;
// ===== JSON coming back from the browser =====
#[derive(Deserialize, Debug)]
struct LocationPayload {
id: String,
lat: f64,
lon: f64,
}
// ===== POST /api/location handler =====
async fn receive_location(axum::Json(p): Json<LocationPayload>) -> impl IntoResponse {
println!("Got location: {p:?}");
axum::http::StatusCode::OK
}
// a helper for integration tests or other binaries
pub fn build_router() -> Router {
Router::new()
.route("/status", get(|| async { "OK" }))
.route("/geolocate", get(geolocate::geolocate))
.route("/geolocate", post(receive_location))
.layer(TraceLayer::new_for_http())
}

View File

@@ -0,0 +1,32 @@
use axum_embed::ServeEmbed;
use base_map::build_router;
use rust_embed::RustEmbed;
use tokio::net::TcpListener;
#[derive(RustEmbed, Clone)]
#[folder = "map/dist/"]
struct Assets;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.init();
let listener = TcpListener::bind("127.0.0.1:8080").await?;
let local_address = listener.local_addr()?;
tracing::info!("Server listening on http://{}", local_address);
async fn fallback(uri: axum::http::Uri) -> (axum::http::StatusCode, String) {
(axum::http::StatusCode::NOT_FOUND, format!("No route for {uri}"))
}
let serve_assets = ServeEmbed::<Assets>::new();
let router = build_router();
let app = router
.nest_service("/", serve_assets)
.fallback(fallback);
axum::serve(listener, app).await?;
Ok(())
}