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

24
crates/base-map/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,93 @@
[package]
name = "base-map"
version = "0.1.0"
edition = "2021"
description = "Rust backend + Vite frontend"
license = "MIT OR Apache-2.0"
# Tell Cargo to run our build script
build = "build.rs"
# ────────────────────────────────────────────────
# Dependencies you actually need in Rust code
# (pick only the ones you want)
# ────────────────────────────────────────────────
[dependencies]
# Web server framework (swap for actixweb, warp, etc.)
axum = { version = "0.7", optional = true, default-features = false, features = ["macros", "tokio", "http1", "json"] }
tokio = { version = "1.46.0", features = ["full"], optional = true}
tower-http = { version = "0.6", features = ["full"] }
tracing = "0.1.37"
tracing-subscriber = "0.3.16"
anyhow = "1"
axum-embed = "0.1.0"
wry = { version = "=0.51.2", features = ["protocol", "os-webview"] }
tao = { version = "0.34"}
web-sys = { version = "0.3.77", features = [
"console",
"Geolocation",
"Navigator",
"Window",
"Document",
"Element",
"Position",
"PositionOptions",
"PositionError",
"Coordinates"
] }
# Staticfile embedding helper (compile assets straight into the binary).
# If you prefer reading from disk at runtime, delete this.
rust-embed = { version = "8", optional = true }
serde = { version = "1.0.219", features = ["derive"] }
# ────────────────────────────────────────────────
# Devonly dependencies (examples, tests, benches)
# ────────────────────────────────────────────────
[dev-dependencies]
serde_json = "1"
# ────────────────────────────────────────────────
# Features let you strip out things you dont want
# by disabling default features in your workspace
# ────────────────────────────────────────────────
[features]
default = ["server", "embed-assets"]
# Feature that pulls in the server stack
server = ["axum", "tokio"]
# Feature that embeds Vites dist/ into the binary
embed-assets = ["rust-embed"]
# ────────────────────────────────────────────────
# Tell Cargo what NOT to publish to crates.io
# ────────────────────────────────────────────────
[package.metadata]
# Keep your node_modules and dist/ out of the crate
# published to crates.io (theyre huge and useless there)
exclude = [
"map/node_modules/**",
"map/dist/**"
]
# ────────────────────────────────────────────────
# Optional: workspace setup
# ────────────────────────────────────────────────
# If this lives inside a workspace, remove [workspace] here
# and put these paths in the root Cargo.toml instead.
[lib]
# Default name is fine; no need to set anything unless you want cdylib, etc.
path = "src/lib.rs"
# Optional: multiple binaries live in src/bin/
[[bin]]
name = "server" # produces target/release/server
path = "src/main.rs"
# Optional: multiple binaries live in src/bin/
[[bin]]
name = "app" # produces target/release/server
path = "src/app.rs"

22
crates/base-map/README.md Normal file
View File

@@ -0,0 +1,22 @@
# YachtPit Base Map Crate
This crate is intended to provide a production build system and independent rust development environment for geospatial maps within yachtpit.
## Overview
// TODO
## Features
// TODO
## Usage
// TODO
```shell
cargo run --bin server
cargo run --bin app
#Error: User denied Geolocation
```
## Dependencies
// TODO
## Architecture
// TODO

120
crates/base-map/build.rs Normal file
View File

@@ -0,0 +1,120 @@
// build.rs
use std::{
env,
fs,
path::{Path, PathBuf},
process::Command,
};
///
/// {
/// "name": "yachtpit",
/// "private": true,
/// "workspaces": ["packages/*"],
/// "scripts": {
/// "build-and-deploy-map": "cd crates/base-map && npm run build && cd ../.. && mkdir -p crates/yachtpit/assets/ui/packages/base-map/dist && cp -r packages/base-map/dist/* crates/yachtpit/assets/ui/packages/base-map/dist/ && cp -r packages/base-map/dist/assets crates/yachtpit/assets/ui/",
/// "postinstall": "npm run build-and-deploy-map"
/// },
/// "devDependencies": {
/// "@types/bun": "latest"
/// },
/// "peerDependencies": {
/// "typescript": "^5"
/// }
/// }
///
fn main() {
// ---------- 0. Locate the map frontend ----------
let map_dir = Path::new("map");
assert!(
map_dir.join("package.json").exists(),
"Expected `map/package.json` is your map frontend in `map/`?"
);
// ---------- 1. Tell Cargo when to rerun ----------
println!("cargo:rerun-if-changed={}", map_dir.join("package.json").display());
println!("cargo:rerun-if-changed={}", map_dir.join("vite.config.ts").display());
println!("cargo:rerun-if-changed={}", map_dir.join("vite.config.js").display());
println!("cargo:rerun-if-changed={}", map_dir.join("src").display());
// ---------- 2. Install & build ----------
run("npm", &["install"], map_dir);
run("npm", &["run", "build"], map_dir); // assumes "build": "tsc -b && vite build" in package.json
// ---------- 3. Copy artefacts following build-and-deploy-map script ----------
let dist = map_dir.join("dist");
let crate_asset_output = out_static_dir();
// Create target directory: crates/yachtpit/assets/ui/packages/base-map/dist
let base_map_dest = Path::new("../yachtpit/assets/ui/packages/base-map/dist");
if base_map_dest.exists() {
fs::remove_dir_all(&base_map_dest).expect("clearing old base-map dist failed");
}
if crate_asset_output.exists() {
fs::remove_dir_all(&crate_asset_output).expect("clearing old base-map dist failed");
}
fs::create_dir_all(&base_map_dest).unwrap();
fs::create_dir_all(&crate_asset_output).unwrap();
// Copy dist/* to crates/yachtpit/assets/ui/packages/base-map/dist/
copy_dir(&dist, &base_map_dest).expect("copying base-map dist/ failed");
copy_dir(&dist, &crate_asset_output).expect("crate asset output failed");
// Copy dist/assets to crates/yachtpit/assets/ui/assets (if assets exist)
let dist_assets = dist.join("assets");
if dist_assets.exists() {
let ui_assets_dest = Path::new("../yachtpit/assets/ui/assets");
if ui_assets_dest.exists() {
fs::remove_dir_all(&ui_assets_dest).expect("clearing old ui assets failed");
}
fs::create_dir_all(&ui_assets_dest).unwrap();
copy_dir(&dist_assets, &ui_assets_dest).expect("copying assets to ui/ failed");
}
}
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
fn run(cmd: &str, args: &[&str], cwd: &Path) {
let ok = Command::new(cmd)
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|_| panic!("failed to spawn `{cmd}`; is it on PATH?"))
.success();
if !ok {
panic!("command `{cmd} {}` failed", args.join(" "));
}
}
/// `$OUT_DIR/../../../static` → a sibling of your crate root inside `target/*/static`.
fn out_static_dir() -> PathBuf {
let out_dir = env::var("OUT_DIR").expect("OUT_DIR env var is set by Cargo");
PathBuf::from(out_dir)
.parent() // build/
.and_then(|p| p.parent()) // <hash>/
.and_then(|p| p.parent()) // release/
.map(|p| p.join("static"))
.expect("could not compute static dir")
}
/// Recursively copy a directory.
fn copy_dir(src: &Path, dst: &Path) -> std::io::Result<()> {
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
fs::create_dir_all(&dst_path)?;
copy_dir(&entry.path(), &dst_path)?;
} else {
fs::copy(entry.path(), dst_path)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,69 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5585
crates/base-map/map/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,42 @@
{
"name": "base-map",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint ",
"preview": "vite preview",
"background-server": "(cd ../ && cargo run &)"
},
"dependencies": {
"next-themes": "^0.4.6",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-icons": "^5.5.0"
},
"devDependencies": {
"@chakra-ui/react": "^3.21.1",
"@emotion/react": "^11.14.0",
"@eslint/js": "^9.29.0",
"@types/js-cookie": "^3.0.6",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react-swc": "^3.10.2",
"eslint": "^9.29.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"js-cookie": "^3.0.5",
"mapbox-gl": "^3.13.0",
"react-map-gl": "^8.0.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.34.1",
"vite": "^7.0.0",
"@tauri-apps/plugin-geolocation": "^2.3.0",
"vite-tsconfig-paths": "^5.1.4",
"bevy_flurx_api": "^0.1.0",
"geojson": "^0.5.0"
}
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,238 @@
// import Map from 'react-map-gl/mapbox';
// import {Source, Layer} from 'react-map-gl/maplibre';
import 'mapbox-gl/dist/mapbox-gl.css';
import {Box, Button, HStack, Input} from '@chakra-ui/react';
import {useCallback, useEffect, useState} from "react";
import MapNext from "@/MapNext.tsx";
// import type {FeatureCollection} from 'geojson';
// import type {CircleLayerSpecification} from "mapbox-gl";
// public key
const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
// const vesselLayerStyle: CircleLayerSpecification = {
// id: 'vessel',
// type: 'circle',
// paint: {
// 'circle-radius': 8,
// 'circle-color': '#ff4444',
// 'circle-stroke-width': 2,
// 'circle-stroke-color': '#ffffff'
// },
// source: ''
// };
// Types for bevy_flurx_ipc communication
interface GpsPosition {
latitude: number;
longitude: number;
zoom: number;
}
interface VesselStatus {
latitude: number;
longitude: number;
heading: number;
speed: number;
}
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// token: string | null;
// }
function App() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
// Map state that can be updated from Rust
// const [mapView, setMapView] = useState({
// longitude: -122.4,
// latitude: 37.8,
// zoom: 14
// });
// Vessel position state
// const [vesselPosition, setVesselPosition] = useState<VesselStatus | null>(null);
// Create vessel geojson data
// const vesselGeojson: FeatureCollection = {
// type: 'FeatureCollection',
// features: vesselPosition ? [
// {
// type: 'Feature',
// geometry: {
// type: 'Point',
// coordinates: [vesselPosition.longitude, vesselPosition.latitude]
// },
// properties: {
// title: 'Vessel Position',
// heading: vesselPosition.heading,
// speed: vesselPosition.speed
// }
// }
// ] : []
// };
// Button click handlers
const handleNavigationClick = useCallback(async () => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try {
await (window as any).__FLURX__.invoke("navigation_clicked");
console.log('Navigation clicked');
} catch (error) {
console.error('Failed to invoke navigation_clicked:', error);
}
}
}, []);
const handleSearchClick = useCallback(async () => {
setIsSearchOpen(!isSearchOpen);
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try {
await (window as any).__FLURX__.invoke("search_clicked");
console.log('Search clicked');
} catch (error) {
console.error('Failed to invoke search_clicked:', error);
}
}
}, []);
// const handleMapViewChange = useCallback(async (evt: any) => {
// const { longitude, latitude, zoom } = evt.viewState;
// setMapView({ longitude, latitude, zoom });
//
// if (typeof window !== 'undefined' && (window as any).__FLURX__) {
// try {
// const mapViewParams: MapViewParams = {
// latitude,
// longitude,
// zoom
// };
// await (window as any).__FLURX__.invoke("map_view_changed", mapViewParams);
// console.log('Map view changed:', mapViewParams);
// } catch (error) {
// console.error('Failed to invoke map_view_changed:', error);
// }
// }
// }, []);
// Poll for vessel status updates
useEffect(() => {
const pollVesselStatus = async () => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
console.log('Vessel status:', vesselStatus);
// setVesselPosition(vesselStatus);
} catch (error) {
console.error('Failed to get vessel status:', error);
}
}
};
// Poll every 5 seconds
const interval = setInterval(pollVesselStatus, 5000);
// Also poll immediately
pollVesselStatus();
return () => clearInterval(interval);
}, []);
// Initialize map with data from Rust
useEffect(() => {
const initializeMap = async () => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
try {
const mapInit: GpsPosition = await (window as any).__FLURX__.invoke("get_map_init");
console.log('Map initialization data:', mapInit);
// setMapView({
// latitude: mapInit.latitude,
// longitude: mapInit.longitude,
// zoom: mapInit.zoom
// });
} catch (error) {
console.error('Failed to get map initialization data:', error);
}
}
};
initializeMap();
}, []);
return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */
<Box w="100vw" h="100vh" position="relative" overflow="hidden">
{/* Button bar — absolutely positioned inside the wrapper */}
<HStack position="absolute" top={4} right={4} zIndex={1}>
<Box
display="flex"
alignItems="center"
>
<Button
colorScheme="teal"
size="sm"
variant="solid"
onClick={handleSearchClick}
mr={2}
>
Search
</Button>
{isSearchOpen && <Box
w="200px"
transition="all 0.3s"
transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
opacity={isSearchOpen ? 1 : 0}
color="white"
>
<Input
placeholder="Search..."
size="sm"
_placeholder={{
color: "#d1cfcf"
}}
/>
</Box>}
</Box>
<Button
colorScheme="blue"
size="sm"
variant="solid"
onClick={handleNavigationClick}
>
Layer
</Button>
</HStack>
<MapNext mapboxPublicKey={atob(key)}/>
{/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/}
{/* onMove={handleMapViewChange}*/}
{/* mapStyle="mapbox://styles/mapbox/dark-v11"*/}
{/* reuseMaps*/}
{/* attributionControl={false}*/}
{/* style={{width: '100%', height: '100%'}} // let the wrapper dictate size*/}
{/*>*/}
{/* /!*{vesselPosition && (*!/*/}
{/* /!* <Source id="vessel-data" type="geojson" data={vesselGeojson}>*!/*/}
{/* /!* <Layer {...vesselLayerStyle} />*!/*/}
{/* /!* </Source>*!/*/}
{/* /!*)}*!/*/}
{/*</Map>*/}
</Box>
);
}
export default App;

View File

@@ -0,0 +1,115 @@
import {useState, useMemo} from 'react';
import Map, {
Marker,
Popup,
NavigationControl,
FullscreenControl,
ScaleControl,
GeolocateControl
} from 'react-map-gl/mapbox';
import ControlPanel from './control-panel.tsx';
import Pin from './pin.tsx';
import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react";
export default function MapNext(props: any = {mapboxPublicKey: ""} as any) {
const [popupInfo, setPopupInfo] = useState(null);
const pins = useMemo(
() =>
PORTS.map((city, index) => (
<Marker
key={`marker-${index}`}
longitude={city.longitude}
latitude={city.latitude}
anchor="bottom"
onClick={e => {
// If we let the click event propagates to the map, it will immediately close the popup
// with `closeOnClick: true`
e.originalEvent.stopPropagation();
/*
src/MapNext.tsx:34:38 - error TS2345: Argument of type '{ city: string; population: string; image: string; state: string; latitude: number; longitude: number; }' is not assignable to parameter of type 'SetStateAction<null>'.
Type '{ city: string; population: string; image: string; state: string; latitude: number; longitude: number; }' provides no match for the signature '(prevState: null): null'.
*/
// @ts-ignore
setPopupInfo(city);
}}
>
<Pin />
</Marker>
)),
[]
);
return (
<Box>
<Map
initialViewState={{
latitude: 40,
longitude: -100,
zoom: 3.5,
bearing: 0,
pitch: 0
}}
mapStyle="mapbox://styles/geoffsee/cmd1qz39x01ga01qv5acea02y"
mapboxAccessToken={props.mapboxPublicKey}
style={{position: "fixed", width: '100%', height: '100%', bottom: 0, top: 0, left: 0, right: 0}}
>
<GeolocateControl showUserHeading={true} showUserLocation={true} position="top-left" />
<FullscreenControl position="top-left" />
<NavigationControl position="top-left" />
<ScaleControl />
{pins}
{popupInfo && (
<Popup
anchor="top"
/*
src/MapNext.tsx:66:53 - error TS2339: Property 'longitude' does not exist on type 'never'.
66 longitude={Number(popupInfo.longitude)}
*/
// @ts-ignore
longitude={Number(popupInfo.longitude)}
/*
src/MapNext.tsx:67:52 - error TS2339: Property 'latitude' does not exist on type 'never'.
67 latitude={Number(popupInfo.latitude)}
~~~~~~~~
*/
// @ts-ignore
latitude={Number(popupInfo.latitude)}
onClose={() => setPopupInfo(null)}
>
<div>
{/*src/MapNext.tsx:71:40 - error TS2339: Property 'city' does not exist on type 'never'.
71 {popupInfo.city}, {popupInfo.state} |{' '}
~~~~*/}
{/*@ts-ignore*/}{/*@ts-ignore*/}
{popupInfo.city},{popupInfo.state}
{/*@ts-ignore*/}
<a
target="_new"
href={`http://en.wikipedia.org/w/index.php?title=Special:Search&search=${(popupInfo as any).city}, ${(popupInfo as any).state}`}
>
Wikipedia
</a>
</div>
{/*@ts-ignore*/}
<img width="100%" src={popupInfo.image} />
</Popup>
)}
</Map>
<ControlPanel />
</Box>
);
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,108 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
const colorMode = forcedTheme || resolvedTheme
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="8" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

View File

@@ -0,0 +1,15 @@
"use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode.tsx"
export function Provider(props: ColorModeProviderProps) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
</ChakraProvider>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "bottom-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

View File

@@ -0,0 +1,46 @@
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
import * as React from "react"
export interface TooltipProps extends ChakraTooltip.RootProps {
showArrow?: boolean
portalled?: boolean
portalRef?: React.RefObject<HTMLElement>
content: React.ReactNode
contentProps?: ChakraTooltip.ContentProps
disabled?: boolean
}
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
},
)

View File

@@ -0,0 +1,29 @@
import * as React from 'react';
function ControlPanel() {
return (
<div className="control-panel">
<h3>Marker, Popup, NavigationControl and FullscreenControl </h3>
<p>
Map showing top 20 most populated cities of the United States. Click on a marker to learn
more.
</p>
<p>
Data source:{' '}
<a href="https://en.wikipedia.org/wiki/List_of_United_States_cities_by_population">
Wikipedia
</a>
</p>
<div className="source-link">
<a
href="https://github.com/visgl/react-map-gl/tree/8.0-release/examples/mapbox/controls"
target="_new"
>
View Code
</a>
</div>
</div>
);
}
export default React.memo(ControlPanel);

View File

@@ -0,0 +1,12 @@
import {StrictMode} from 'react'
import {createRoot} from 'react-dom/client'
import App from './App.tsx'
import {Provider} from "@/components/ui/provider.tsx"
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Provider>
<App/>
</Provider>
</StrictMode>,
)

View File

@@ -0,0 +1,21 @@
import * as React from 'react';
const ICON = `M20.2,15.7L20.2,15.7c1.1-1.6,1.8-3.6,1.8-5.7c0-5.6-4.5-10-10-10S2,4.5,2,10c0,2,0.6,3.9,1.6,5.4c0,0.1,0.1,0.2,0.2,0.3
c0,0,0.1,0.1,0.1,0.2c0.2,0.3,0.4,0.6,0.7,0.9c2.6,3.1,7.4,7.6,7.4,7.6s4.8-4.5,7.4-7.5c0.2-0.3,0.5-0.6,0.7-0.9
C20.1,15.8,20.2,15.8,20.2,15.7z`;
const pinStyle = {
cursor: 'pointer',
fill: '#d00',
stroke: 'none'
};
function Pin({size = 20}) {
return (
<svg height={size} viewBox="0 0 24 24" style={pinStyle}>
<path d={ICON} />
</svg>
);
}
export default React.memo(Pin);

View File

@@ -0,0 +1,22 @@
[
{"city":"New York","population":"8,175,133","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Above_Gotham.jpg/240px-Above_Gotham.jpg","state":"New York","latitude":40.6643,"longitude":-73.9385},
{"city":"Los Angeles","population":"3,792,621","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/5/57/LA_Skyline_Mountains2.jpg/240px-LA_Skyline_Mountains2.jpg","state":"California","latitude":34.0194,"longitude":-118.4108},
{"city":"Chicago","population":"2,695,598","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/8/85/2008-06-10_3000x1000_chicago_skyline.jpg/240px-2008-06-10_3000x1000_chicago_skyline.jpg","state":"Illinois","latitude":41.8376,"longitude":-87.6818},
{"city":"Houston","population":"2,100,263","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg/240px-Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg","state":"Texas","latitude":29.7805,"longitude":-95.3863},
{"city":"Phoenix","population":"1,445,632","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Downtown_Phoenix_Aerial_Looking_Northeast.jpg/207px-Downtown_Phoenix_Aerial_Looking_Northeast.jpg","state":"Arizona","latitude":33.5722,"longitude":-112.0880},
{"city":"Philadelphia","population":"1,526,006","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Philly_skyline.jpg/240px-Philly_skyline.jpg","state":"Pennsylvania","latitude":40.0094,"longitude":-75.1333},
{"city":"San Antonio","population":"1,327,407","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/f/ff/Downtown_San_Antonio_View.JPG/240px-Downtown_San_Antonio_View.JPG","state":"Texas","latitude":29.4724,"longitude":-98.5251},
{"city":"San Diego","population":"1,307,402","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/5/53/US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg/240px-US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg","state":"California","latitude":32.8153,"longitude":-117.1350},
{"city":"Dallas","population":"1,197,816","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/a/ab/Dallas_skyline_daytime.jpg/240px-Dallas_skyline_daytime.jpg","state":"Texas","latitude":32.7757,"longitude":-96.7967},
{"city":"San Jose","population":"945,942","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/1/1e/Downtown_San_Jose_skyline.PNG/240px-Downtown_San_Jose_skyline.PNG","state":"California","latitude":37.2969,"longitude":-121.8193},
{"city":"Austin","population":"790,390","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/9/97/Austin2012-12-01.JPG/240px-Austin2012-12-01.JPG","state":"Texas","latitude":30.3072,"longitude":-97.7560},
{"city":"Jacksonville","population":"821,784","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg/240px-Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg","state":"Florida","latitude":30.3370,"longitude":-81.6613},
{"city":"San Francisco","population":"805,235","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/San_Francisco_skyline_from_Coit_Tower.jpg/240px-San_Francisco_skyline_from_Coit_Tower.jpg","state":"California","latitude":37.7751,"longitude":-122.4193},
{"city":"Columbus","population":"787,033","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Columbus-ohio-skyline-panorama.jpg/240px-Columbus-ohio-skyline-panorama.jpg","state":"Ohio","latitude":39.9848,"longitude":-82.9850},
{"city":"Indianapolis","population":"820,445","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/1/16/Downtown_indy_from_parking_garage_zoom.JPG/213px-Downtown_indy_from_parking_garage_zoom.JPG","state":"Indiana","latitude":39.7767,"longitude":-86.1459},
{"city":"Fort Worth","population":"741,206","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/d/db/FortWorthTexasSkylineW.jpg/240px-FortWorthTexasSkylineW.jpg","state":"Texas","latitude":32.7795,"longitude":-97.3463},
{"city":"Charlotte","population":"731,424","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/7/7d/Charlotte_skyline45647.jpg/222px-Charlotte_skyline45647.jpg","state":"North Carolina","latitude":35.2087,"longitude":-80.8307},
{"city":"Seattle","population":"608,660","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/3/36/SeattleI5Skyline.jpg/240px-SeattleI5Skyline.jpg","state":"Washington","latitude":47.6205,"longitude":-122.3509},
{"city":"Denver","population":"600,158","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/0/0b/DenverCP.JPG/240px-DenverCP.JPG","state":"Colorado","latitude":39.7618,"longitude":-104.8806},
{"city":"El Paso","population":"649,121","image":"http://upload.wikimedia.org/wikipedia/commons/thumb/6/6d/Downtown_El_Paso_at_sunset.jpeg/240px-Downtown_El_Paso_at_sunset.jpeg","state":"Texas","latitude":31.8484,"longitude":-106.4270}
]

View File

@@ -0,0 +1,22 @@
[
{"city":"New York","population":"8,335,897","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Above_Gotham.jpg/240px-Above_Gotham.jpg","state":"New York","latitude":40.7128,"longitude":-74.0060},
{"city":"Los Angeles","population":"3,822,238","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/5/57/LA_Skyline_Mountains2.jpg/240px-LA_Skyline_Mountains2.jpg","state":"California","latitude":34.0522,"longitude":-118.2437},
{"city":"Long Beach","population":"456,062","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/d/d9/Long_Beach_skyline_from_Shoreline_Village.jpg/240px-Long_Beach_skyline_from_Shoreline_Village.jpg","state":"California","latitude":33.7701,"longitude":-118.1937},
{"city":"Seattle","population":"749,256","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/36/SeattleI5Skyline.jpg/240px-SeattleI5Skyline.jpg","state":"Washington","latitude":47.6062,"longitude":-122.3321},
{"city":"San Francisco","population":"808,437","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/6/6a/San_Francisco_skyline_from_Coit_Tower.jpg/240px-San_Francisco_skyline_from_Coit_Tower.jpg","state":"California","latitude":37.7749,"longitude":-122.4194},
{"city":"San Diego","population":"1,386,932","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/5/53/US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg/240px-US_Navy_110604-N-NS602-574_Navy_and_Marine_Corps_personnel%2C_along_with_community_leaders_from_the_greater_San_Diego_area_come_together_to_commemora.jpg","state":"California","latitude":32.7157,"longitude":-117.1611},
{"city":"Norfolk","population":"235,089","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/6/6e/Norfolk_Skyline_from_Portsmouth.jpg/240px-Norfolk_Skyline_from_Portsmouth.jpg","state":"Virginia","latitude":36.8508,"longitude":-76.2859},
{"city":"Miami","population":"449,514","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Miami_skyline_201807_cat.jpg/240px-Miami_skyline_201807_cat.jpg","state":"Florida","latitude":25.7617,"longitude":-80.1918},
{"city":"Boston","population":"675,647","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/Boston_skyline_and_Boston_Harbor.jpg/240px-Boston_skyline_and_Boston_Harbor.jpg","state":"Massachusetts","latitude":42.3601,"longitude":-71.0589},
{"city":"Baltimore","population":"585,708","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Baltimore_Skyline.jpg/240px-Baltimore_Skyline.jpg","state":"Maryland","latitude":39.2904,"longitude":-76.6122},
{"city":"Charleston","population":"151,612","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Charleston_SC_Skyline.jpg/240px-Charleston_SC_Skyline.jpg","state":"South Carolina","latitude":32.7765,"longitude":-79.9311},
{"city":"Savannah","population":"147,780","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/4/40/Savannah_GA%2C_River_Street.jpg/240px-Savannah_GA%2C_River_Street.jpg","state":"Georgia","latitude":32.0809,"longitude":-81.0912},
{"city":"Tampa","population":"403,364","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Tampa_skyline_from_South%2C_2022.jpg/240px-Tampa_skyline_from_South%2C_2022.jpg","state":"Florida","latitude":27.9506,"longitude":-82.4572},
{"city":"Mobile","population":"187,041","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/7/70/Mobile_skyline_from_Mobile_River.jpg/240px-Mobile_skyline_from_Mobile_River.jpg","state":"Alabama","latitude":30.6954,"longitude":-88.0399},
{"city":"Anchorage","population":"288,121","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Anchorage_skyline_and_susitna.jpg/240px-Anchorage_skyline_and_susitna.jpg","state":"Alaska","latitude":61.2181,"longitude":-149.9003},
{"city":"Portland","population":"68,408","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/4/48/Portland_Maine_skyline.jpg/240px-Portland_Maine_skyline.jpg","state":"Maine","latitude":43.6591,"longitude":-70.2568},
{"city":"Honolulu","population":"349,547","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/1/10/Honolulu_and_Diamond_Head.jpg/240px-Honolulu_and_Diamond_Head.jpg","state":"Hawaii","latitude":21.3069,"longitude":-157.8583},
{"city":"New Orleans","population":"376,971","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/New_Orleans_skyline_sunset_Dec_28_2021_PANO_DSC07177-07179.jpg/240px-New_Orleans_skyline_sunset_Dec_28_2021_PANO_DSC07177-07179.jpg","state":"Louisiana","latitude":29.9511,"longitude":-90.0715},
{"city":"Jacksonville","population":"971,319","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/f/f3/Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg/240px-Skyline_of_Jacksonville_FL%2C_South_view_20160706_1.jpg","state":"Florida","latitude":30.3322,"longitude":-81.6557},
{"city":"Houston","population":"2,302,878","image":"https://upload.wikimedia.org/wikipedia/commons/thumb/6/60/Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg/240px-Aerial_views_of_the_Houston%2C_Texas%2C_28005u.jpg","state":"Texas","latitude":29.7604,"longitude":-95.3698}
]

1
crates/base-map/map/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src"
]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json"},
{ "path": "./tsconfig.node.json"}
],
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"vite.config.ts"
]
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
import tsconfigPaths from "vite-tsconfig-paths"
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
})

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(())
}

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -21,3 +21,18 @@ datalink = { path = "../datalink" }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
datalink-provider = { path = "../datalink-provider" }
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = { workspace = true }
web-sys = { version = "0.3.77", features = [
"console",
"Geolocation",
"Navigator",
"Window",
"Document",
"Element",
"Position",
"PositionOptions",
"PositionError",
"Coordinates"
] }

View File

@@ -0,0 +1,115 @@
// src/geo_plugin.rs
use bevy::prelude::*;
use std::sync::{Arc, Mutex};
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::{closure::Closure, JsCast};
#[cfg(target_arch = "wasm32")]
use web_sys::window;
#[derive(Resource, Default)]
pub struct UserLocation {
pub lat: f64,
pub lon: f64,
pub accuracy: f64,
pub fresh: bool,
}
#[derive(Resource)]
pub struct LocationData {
pub data: Arc<Mutex<Option<(f64, f64, f64)>>>,
}
impl Default for LocationData {
fn default() -> Self {
Self {
data: Arc::new(Mutex::new(None)),
}
}
}
pub struct GeoPlugin;
impl Plugin for GeoPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UserLocation>()
.init_resource::<LocationData>();
#[cfg(target_arch = "wasm32")]
{
app.add_systems(Startup, request_location)
.add_systems(Update, update_location);
}
}
}
#[cfg(target_arch = "wasm32")]
fn request_location(location_data: Res<LocationData>) {
let window = match window() {
Some(w) => w,
None => {
warn!("No window object available");
return;
}
};
let geo = match window.navigator().geolocation() {
Ok(g) => g,
Err(_) => {
warn!("Geolocation not available");
return;
}
};
let data_clone = location_data.data.clone();
let success = Closure::<dyn FnMut(web_sys::Position)>::new(move |pos: web_sys::Position| {
let c: web_sys::Coordinates = pos.coords();
if let Ok(mut data) = data_clone.lock() {
*data = Some((c.latitude(), c.longitude(), c.accuracy()));
}
});
let error = Closure::<dyn FnMut(web_sys::PositionError)>::new(move |err: web_sys::PositionError| {
match err.code() {
1 => {
warn!("Geolocation permission denied. This may be due to:");
warn!(" - User denied location access");
warn!(" - Insecure connection (HTTP instead of HTTPS)");
warn!(" - Browser security settings");
warn!(" Consider serving over HTTPS for geolocation access");
},
2 => {
warn!("Geolocation position unavailable: {}", err.message());
},
3 => {
warn!("Geolocation timeout: {}", err.message());
},
_ => {
warn!("Geolocation error: {} (code: {})", err.message(), err.code());
}
}
});
// watch_position keeps updating; get_current_position is oneshot
match geo.watch_position_with_error_callback(success.as_ref().unchecked_ref(), Some(error.as_ref().unchecked_ref())) {
Ok(_) => {
success.forget(); // leak the closure so it lives forever
error.forget(); // leak the error closure too
}
Err(e) => {
warn!("Failed to start watching position: {:?}", e);
}
}
}
#[cfg(target_arch = "wasm32")]
fn update_location(mut loc: ResMut<UserLocation>, location_data: Res<LocationData>) {
if let Ok(mut data) = location_data.data.lock() {
if let Some((lat, lon, accuracy)) = data.take() {
loc.lat = lat;
loc.lon = lon;
loc.accuracy = accuracy;
loc.fresh = true;
}
}
}

View File

@@ -5,6 +5,7 @@ mod vessel;
mod ais;
mod gps;
mod radar;
mod geo_plugin;
// Re-export components from the components crate
pub use components::{
@@ -16,3 +17,5 @@ pub use components::{
pub use world::player::{get_vessel_systems, setup_instrument_cluster_system, PlayerPlugin};
pub use vessel::vessel_systems::{create_vessel_systems, AisSystem, GpsSystem, RadarSystem, SystemInteraction, SystemStatus, VesselSystem};
pub use geo_plugin::GeoPlugin;

View File

@@ -59,8 +59,19 @@ rand = { version = "0.8.3" }
webbrowser = { version = "1", features = ["hardened"] }
systems = { path = "../systems" }
components = { path = "../components" }
wasm-bindgen = "0.2"
web-sys = { version = "0.3.53", features = ["Document", "Element", "HtmlElement", "Window"] }
wasm-bindgen = { workspace = true }
web-sys = { version = "0.3", features = [
"console",
"Geolocation",
"Navigator",
"Window",
"Document",
"Element",
"Position",
"PositionOptions",
"PositionError",
"Coordinates"
] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
@@ -78,9 +89,12 @@ winit = "0.30"
bevy_webview_wry = { version = "0.4", default-features = false, features = ["api"] }
bevy_flurx = "0.11"
bevy_flurx_ipc = "0.4.0"
# (run `cargo tree | grep wry` and use the version you see for bevy_webview_wry)
wry = { version = "=0.51.2", optional = true, features = ["os-webview"] } # GPS support for native platforms - placeholder for future real GPS implementation
[target.'cfg(target_arch = "wasm32")'.dependencies]
tokio = { version = "1.0", features = ["rt"] }
console_error_panic_hook = "0.1"
[build-dependencies]
embed-resource = "1"

View File

@@ -51,3 +51,9 @@ body {
#bevy {
z-index: 2;
}
#yachtpit-canvas {
width: 100%;
height: 100%;
display: block;
}

View File

@@ -3,6 +3,7 @@
mod core;
mod ui;
mod services;
use bevy::app::App;
#[cfg(debug_assertions)]
@@ -11,7 +12,10 @@ use bevy::prelude::*;
use crate::core::{ActionsPlugin, SystemManagerPlugin};
use crate::core::system_manager::SystemManager;
use crate::ui::{LoadingPlugin, MenuPlugin, GpsMapPlugin};
use crate::services::GpsServicePlugin;
use systems::{PlayerPlugin, setup_instrument_cluster, get_vessel_systems};
#[cfg(target_arch = "wasm32")]
use systems::GeoPlugin;
// See https://bevy-cheatbook.github.io/programming/states.html
#[derive(States, Default, Clone, Eq, PartialEq, Debug, Hash)]
@@ -41,13 +45,19 @@ impl Plugin for GamePlugin {
LoadingPlugin,
MenuPlugin,
GpsMapPlugin,
GpsServicePlugin,
ActionsPlugin,
SystemManagerPlugin,
PlayerPlugin,
))
.add_systems(OnEnter(GameState::Playing), (setup_instrument_cluster, initialize_vessel_systems));
#[cfg(target_arch = "wasm32")]
{
app.add_plugins(GeoPlugin);
}
#[cfg(debug_assertions)]
{
app.add_plugins((

View File

@@ -41,29 +41,38 @@ fn main() {
.run();
#[cfg(target_arch = "wasm32")]
App::new()
.insert_resource(ClearColor(Color::NONE))
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
// Bind to canvas included in `index.html`
canvas: Some("#yachtpit-canvas".to_owned()),
fit_canvas_to_parent: true,
// Tells wasm not to override default event handling, like F5 and Ctrl+R
prevent_default_event_handling: false,
{
// Add console logging for WASM debugging
console_error_panic_hook::set_once();
info!("Starting WASM Bevy application");
App::new()
.insert_resource(ClearColor(Color::srgb(0.1, 0.1, 0.1))) // Dark gray background instead of transparent
.add_plugins(
DefaultPlugins
.set(WindowPlugin {
primary_window: Some(Window {
// Bind to canvas included in `index.html`
canvas: Some("#yachtpit-canvas".to_owned()),
fit_canvas_to_parent: true,
// Tells wasm not to override default event handling, like F5 and Ctrl+R
prevent_default_event_handling: false,
..default()
}),
..default()
})
.set(AssetPlugin {
meta_check: AssetMetaCheck::Never,
..default()
}),
..default()
})
.set(AssetPlugin {
meta_check: AssetMetaCheck::Never,
..default()
}),
)
.add_plugins(GamePlugin)
.add_systems(Startup, set_window_icon)
.run();
)
.add_plugins(GamePlugin)
.add_systems(Startup, || {
info!("WASM Bevy startup system running");
})
.run();
}
}

View File

@@ -0,0 +1,237 @@
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GpsData {
pub latitude: f64,
pub longitude: f64,
pub altitude: Option<f64>,
pub accuracy: Option<f64>,
pub heading: Option<f64>,
pub speed: Option<f64>,
pub timestamp: f64,
}
#[derive(Resource, Default)]
pub struct GpsService {
pub current_position: Option<GpsData>,
pub is_enabled: bool,
pub last_update: f64,
}
impl GpsService {
pub fn new() -> Self {
Self {
current_position: None,
is_enabled: false,
last_update: 0.0,
}
}
pub fn enable(&mut self) {
self.is_enabled = true;
info!("GPS service enabled");
}
pub fn disable(&mut self) {
self.is_enabled = false;
info!("GPS service disabled");
}
pub fn update_position(&mut self, gps_data: GpsData) {
self.current_position = Some(gps_data.clone());
self.last_update = gps_data.timestamp;
info!("GPS position updated: lat={:.6}, lon={:.6}", gps_data.latitude, gps_data.longitude);
}
pub fn get_current_position(&self) -> Option<&GpsData> {
self.current_position.as_ref()
}
}
// Native GPS implementation - Mock implementation for demonstration
// TODO: Replace with real GPS hardware access (e.g., using gpsd, CoreLocation, etc.)
#[cfg(not(target_arch = "wasm32"))]
pub fn start_native_gps_tracking(mut gps_service: ResMut<GpsService>, time: Res<Time>) {
use std::time::{SystemTime, UNIX_EPOCH};
if !gps_service.is_enabled {
return;
}
// Mock GPS data that simulates realistic movement
// In a real implementation, this would read from GPS hardware
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64();
// Only update every 2 seconds to simulate realistic GPS update rate
if timestamp - gps_service.last_update < 2.0 {
return;
}
// Simulate GPS coordinates around Monaco with realistic movement
let base_lat = 43.7384;
let base_lon = 7.4246;
let time_factor = time.elapsed_secs() * 0.1;
// Simulate a boat moving in a realistic pattern
let lat_offset = (time_factor.sin() * 0.002) as f64;
let lon_offset = (time_factor.cos() * 0.003) as f64;
let gps_data = GpsData {
latitude: base_lat + lat_offset,
longitude: base_lon + lon_offset,
altitude: Some(0.0), // Sea level
accuracy: Some(3.0), // 3 meter accuracy
heading: Some(((time_factor * 20.0) % 360.0) as f64),
speed: Some(5.2), // 5.2 knots
timestamp,
};
gps_service.update_position(gps_data);
}
// Web GPS implementation using geolocation API
// For web platforms, we'll use a simplified approach that requests position periodically
#[cfg(target_arch = "wasm32")]
pub fn start_web_gps_tracking(mut gps_service: ResMut<GpsService>, time: Res<Time>) {
if !gps_service.is_enabled {
return;
}
// Use Bevy's time instead of std::time for WASM compatibility
let current_time = time.elapsed_secs_f64();
// Only try to get GPS every 5 seconds to avoid overwhelming the browser
if current_time - gps_service.last_update < 5.0 {
return;
}
// For now, use mock data for web as well
// TODO: Implement proper web geolocation API integration using channels or events
let time_factor = time.elapsed_secs() * 0.1;
let base_lat = 43.7384;
let base_lon = 7.4246;
let lat_offset = (time_factor.sin() * 0.001) as f64;
let lon_offset = (time_factor.cos() * 0.002) as f64;
let gps_data = GpsData {
latitude: base_lat + lat_offset,
longitude: base_lon + lon_offset,
altitude: Some(0.0),
accuracy: Some(5.0), // Slightly less accurate on web
heading: Some(((time_factor * 15.0) % 360.0) as f64),
speed: Some(4.8), // Slightly different speed for web
timestamp: current_time,
};
gps_service.update_position(gps_data.clone());
info!("Web GPS position updated: lat={:.6}, lon={:.6}", gps_data.latitude, gps_data.longitude);
}
pub struct GpsServicePlugin;
impl Plugin for GpsServicePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<GpsService>()
.add_systems(Update, (
#[cfg(not(target_arch = "wasm32"))]
start_native_gps_tracking,
#[cfg(target_arch = "wasm32")]
start_web_gps_tracking,
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(target_arch = "wasm32"))]
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn test_gps_service_initialization() {
let mut gps_service = GpsService::new();
assert!(!gps_service.is_enabled);
assert!(gps_service.current_position.is_none());
gps_service.enable();
assert!(gps_service.is_enabled);
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn test_gps_data_update() {
let mut gps_service = GpsService::new();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let test_gps_data = GpsData {
latitude: 43.7384,
longitude: 7.4246,
altitude: Some(0.0),
accuracy: Some(3.0),
heading: Some(45.0),
speed: Some(5.2),
timestamp,
};
gps_service.update_position(test_gps_data.clone());
let current_pos = gps_service.get_current_position().unwrap();
assert_eq!(current_pos.latitude, 43.7384);
assert_eq!(current_pos.longitude, 7.4246);
assert_eq!(current_pos.speed, Some(5.2));
assert_eq!(current_pos.heading, Some(45.0));
}
#[test]
#[cfg(target_arch = "wasm32")]
fn test_gps_data_update_wasm() {
let mut gps_service = GpsService::new();
// Use a mock timestamp for WASM testing
let timestamp = 1234567890.0;
let test_gps_data = GpsData {
latitude: 43.7384,
longitude: 7.4246,
altitude: Some(0.0),
accuracy: Some(3.0),
heading: Some(45.0),
speed: Some(5.2),
timestamp,
};
gps_service.update_position(test_gps_data.clone());
let current_pos = gps_service.get_current_position().unwrap();
assert_eq!(current_pos.latitude, 43.7384);
assert_eq!(current_pos.longitude, 7.4246);
assert_eq!(current_pos.speed, Some(5.2));
assert_eq!(current_pos.heading, Some(45.0));
}
#[test]
fn test_gps_service_enable_disable() {
let mut gps_service = GpsService::new();
// Test initial state
assert!(!gps_service.is_enabled);
// Test enable
gps_service.enable();
assert!(gps_service.is_enabled);
// Test disable
gps_service.disable();
assert!(!gps_service.is_enabled);
}
}

View File

@@ -0,0 +1,3 @@
pub mod gps_service;
pub use gps_service::*;

View File

@@ -2,14 +2,14 @@ use bevy::prelude::*;
use bevy::render::view::RenderLayers;
use bevy::window::Window;
use std::collections::HashMap;
use bevy_flurx::prelude::*;
use bevy_webview_wry::prelude::*;
use serde::{Deserialize, Serialize};
/// Render layer for GPS map entities to isolate them from other cameras
use crate::services::{GpsService, GpsData};
#[cfg(not(target_arch = "wasm32"))]
use bevy_flurx::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use bevy_webview_wry::prelude::*;
use web_sys::window;
/// Render layer for GPS map entities to isolate them from other cameras
const GPS_MAP_LAYER: usize = 1;
@@ -97,8 +97,9 @@ impl Plugin for GpsMapPlugin {
.add_systems(Update, (
handle_gps_map_window_events,
update_map_tiles,
send_periodic_gps_updates,
));
update_gps_from_service,
))
.add_systems(Startup, enable_gps_service);
}
}
@@ -182,18 +183,26 @@ fn spawn_placeholder_map(commands: &mut Commands, _asset_server: &Res<AssetServe
/// Function to spawn the GPS map window
pub fn spawn_gps_map_window(commands: &mut Commands, gps_map_state: &mut ResMut<GpsMapState>) {
#[cfg(not(target_arch = "wasm32"))]
spawn_webview(commands, gps_map_state);
#[cfg(target_arch = "wasm32")]
web_sys::window().unwrap().document().unwrap().create_element("div").unwrap().set_id("bevy-gps-map");
}
#[cfg(not(target_arch = "wasm32"))]
pub fn spawn_webview(commands: &mut Commands, gps_map_state: &mut ResMut<GpsMapState>) {
if gps_map_state.window_id.is_some() {
info!("GPS map window already open");
return;
}
info!("Spawning GPS map window");
// Create a new window for the GPS map
let window_entity = commands
.spawn((
Window {
title: "GPS Navigation - OpenStreetMap".to_string(),
title: "GPS".to_string(),
resolution: (800.0, 600.0).into(),
position: bevy::window::WindowPosition::Centered(
bevy::window::MonitorSelection::Current,
@@ -204,6 +213,8 @@ pub fn spawn_gps_map_window(commands: &mut Commands, gps_map_state: &mut ResMut<
))
.id();
gps_map_state.window_id = Some(window_entity);
// Create a camera for the map window
commands.spawn((
Camera2d,
@@ -217,29 +228,25 @@ pub fn spawn_gps_map_window(commands: &mut Commands, gps_map_state: &mut ResMut<
GpsMapWindow,
));
gps_map_state.window_id = Some(window_entity);
info!("GPS map window spawned with entity: {:?}", window_entity);
#[cfg(not(target_arch = "wasm32"))]
spawn_gps_webview(commands, gps_map_state);
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn_gps_webview(commands: &mut Commands, gps_map_state: &mut ResMut<GpsMapState>) {
if let Some(win) = gps_map_state.window_id {
commands.entity(win).insert((
IpcHandlers::new([
navigation_clicked,
search_clicked,
map_view_changed,
auth_status_changed,
get_map_init,
get_vessel_status
ipc_commands::navigation_clicked,
ipc_commands::search_clicked,
ipc_commands::map_view_changed,
ipc_commands::auth_status_changed,
ipc_commands::get_map_init,
ipc_commands::get_vessel_status
]),
Webview::Uri(WebviewUri::relative_local(
// Using the build output of the base-map package
@@ -249,109 +256,122 @@ fn spawn_gps_webview(commands: &mut Commands, gps_map_state: &mut ResMut<GpsMapS
}
}
// GPS Map IPC Commands using bevy_flurx_ipc
#[cfg(not(target_arch = "wasm32"))]
mod ipc_commands {
use super::*;
/// Handle navigation button click
#[command]
fn navigation_clicked(
WebviewEntity(_entity): WebviewEntity,
) -> Action<(), ()> {
once::run(|_: In<()>| {
info!("Navigation button clicked in React");
// Handle navigation logic here
}).into()
// GPS Map IPC Commands using bevy_flurx_ipc
/// Handle navigation button click
#[command]
pub fn navigation_clicked(
WebviewEntity(_entity): WebviewEntity,
) -> Action<(), ()> {
once::run(|_: In<()>| {
info!("Navigation button clicked in React");
// Handle navigation logic here
}).into()
}
/// Handle search button click
#[command]
pub fn search_clicked(
WebviewEntity(_entity): WebviewEntity,
) -> Action<(), ()> {
once::run(|_: In<()>| {
info!("Search button clicked in React");
// Handle search logic here
}).into()
}
/// Handle map view change
#[command]
pub fn map_view_changed(
In(params): In<MapViewParams>,
WebviewEntity(_entity): WebviewEntity,
) -> Action<(f64, f64, u8), ()> {
once::run(|In((latitude, longitude, zoom)): In<(f64, f64, u8)>, mut gps_map_state: ResMut<GpsMapState>| {
info!("Map view changed: lat={}, lon={}, zoom={}", latitude, longitude, zoom);
gps_map_state.center_lat = latitude;
gps_map_state.center_lon = longitude;
gps_map_state.zoom_level = zoom;
}).with((params.latitude, params.longitude, params.zoom)).into()
}
/// Handle authentication status change
#[command]
pub fn auth_status_changed(
In(params): In<AuthParams>,
WebviewEntity(_entity): WebviewEntity,
) -> Action<(bool, Option<String>), ()> {
once::run(|In((authenticated, token)): In<(bool, Option<String>)>| {
info!("Auth status changed: authenticated={}, token={:?}", authenticated, token);
// Handle authentication status change
}).with((params.authenticated, params.token)).into()
}
/// Get map initialization data
#[command]
pub async fn get_map_init(
WebviewEntity(_entity): WebviewEntity,
task: ReactorTask,
) -> GpsPosition {
task.will(Update, once::run(|gps_map_state: Res<GpsMapState>| {
GpsPosition {
latitude: gps_map_state.center_lat,
longitude: gps_map_state.center_lon,
zoom: gps_map_state.zoom_level,
}
})).await
}
/// Get current vessel status
#[command]
pub async fn get_vessel_status(
WebviewEntity(_entity): WebviewEntity,
task: ReactorTask,
) -> VesselStatus {
task.will(Update, once::run(|gps_map_state: Res<GpsMapState>| {
VesselStatus {
latitude: gps_map_state.vessel_lat,
longitude: gps_map_state.vessel_lon,
heading: gps_map_state.vessel_heading,
speed: gps_map_state.vessel_speed,
}
})).await
}
}
/// Handle search button click
#[command]
fn search_clicked(
WebviewEntity(_entity): WebviewEntity,
) -> Action<(), ()> {
once::run(|_: In<()>| {
info!("Search button clicked in React");
// Handle search logic here
}).into()
/// System to enable GPS service on startup
fn enable_gps_service(mut gps_service: ResMut<GpsService>) {
gps_service.enable();
info!("GPS service enabled for map tracking");
}
/// Handle map view change
#[command]
fn map_view_changed(
In(params): In<MapViewParams>,
WebviewEntity(_entity): WebviewEntity,
) -> Action<(f64, f64, u8), ()> {
once::run(|In((latitude, longitude, zoom)): In<(f64, f64, u8)>, mut gps_map_state: ResMut<GpsMapState>| {
info!("Map view changed: lat={}, lon={}, zoom={}", latitude, longitude, zoom);
gps_map_state.center_lat = latitude;
gps_map_state.center_lon = longitude;
gps_map_state.zoom_level = zoom;
}).with((params.latitude, params.longitude, params.zoom)).into()
}
/// Handle authentication status change
#[command]
fn auth_status_changed(
In(params): In<AuthParams>,
WebviewEntity(_entity): WebviewEntity,
) -> Action<(bool, Option<String>), ()> {
once::run(|In((authenticated, token)): In<(bool, Option<String>)>| {
info!("Auth status changed: authenticated={}, token={:?}", authenticated, token);
// Handle authentication status change
}).with((params.authenticated, params.token)).into()
}
/// Get map initialization data
#[command]
async fn get_map_init(
WebviewEntity(_entity): WebviewEntity,
task: ReactorTask,
) -> GpsPosition {
task.will(Update, once::run(|gps_map_state: Res<GpsMapState>| {
GpsPosition {
latitude: gps_map_state.center_lat,
longitude: gps_map_state.center_lon,
zoom: gps_map_state.zoom_level,
}
})).await
}
/// Get current vessel status
#[command]
async fn get_vessel_status(
WebviewEntity(_entity): WebviewEntity,
task: ReactorTask,
) -> VesselStatus {
task.will(Update, once::run(|gps_map_state: Res<GpsMapState>| {
VesselStatus {
latitude: gps_map_state.vessel_lat,
longitude: gps_map_state.vessel_lon,
heading: gps_map_state.vessel_heading,
speed: gps_map_state.vessel_speed,
}
})).await
}
/// System to send periodic GPS updates for testing
fn send_periodic_gps_updates(
/// System to update GPS map state from GPS service
fn update_gps_from_service(
mut gps_map_state: ResMut<GpsMapState>,
time: Res<Time>,
gps_service: Res<GpsService>,
) {
// Update vessel position every frame for testing
if time.delta_secs() > 0.0 {
// Simulate slight movement around Monaco
let base_lat = 43.6377;
let base_lon = -1.4497;
let offset = (time.elapsed_secs().sin() * 0.001) as f64;
if let Some(gps_data) = gps_service.get_current_position() {
// Update vessel position from real GPS data
gps_map_state.vessel_lat = gps_data.latitude;
gps_map_state.vessel_lon = gps_data.longitude;
gps_map_state.vessel_lat = base_lat + offset;
gps_map_state.vessel_lon = base_lon + offset * 0.5;
gps_map_state.vessel_speed = 5.0 + (time.elapsed_secs().cos() * 2.0) as f64;
gps_map_state.vessel_heading = ((time.elapsed_secs() * 10.0) % 360.0) as f64;
// Update speed and heading if available
if let Some(speed) = gps_data.speed {
gps_map_state.vessel_speed = speed;
}
if let Some(heading) = gps_data.heading {
gps_map_state.vessel_heading = heading;
}
// React side can poll for updates using get_vessel_status command
if time.elapsed_secs() as u32 % 5 == 0 && time.delta_secs() < 0.1 {
info!("Vessel position updated: lat={:.4}, lon={:.4}, speed={:.1}, heading={:.1}",
gps_map_state.vessel_lat, gps_map_state.vessel_lon,
gps_map_state.vessel_speed, gps_map_state.vessel_heading);
// Also update map center to follow vessel if this is the first GPS fix
if gps_map_state.center_lat == 43.6377 && gps_map_state.center_lon == -1.4497 {
gps_map_state.center_lat = gps_data.latitude;
gps_map_state.center_lon = gps_data.longitude;
info!("Map centered on GPS position: lat={:.6}, lon={:.6}",
gps_data.latitude, gps_data.longitude);
}
}
}

View File

@@ -13,18 +13,28 @@ pub struct LoadingPlugin;
/// If interested, take a look at <https://bevy-cheatbook.github.io/features/assets.html>
impl Plugin for LoadingPlugin {
fn build(&self, app: &mut App) {
app.add_loading_state(
LoadingState::new(GameState::Loading)
.continue_to_state(GameState::Playing)
// .load_collection::<AudioAssets>()
// .load_collection::<TextureAssets>(),
);
// Temporarily bypass asset loading and go directly to Playing state
app.add_systems(Startup, || {
info!("LoadingPlugin: Starting up, transitioning to Playing state");
});
app.add_systems(Update, transition_to_playing.run_if(in_state(GameState::Loading)));
// Add a system to hide the loading indicator when transitioning to the Playing state
app.add_systems(OnEnter(GameState::Playing), hide_loading_indicator);
// Add debug systems to track state transitions
app.add_systems(OnEnter(GameState::Loading), || info!("Entered Loading state"));
app.add_systems(OnExit(GameState::Loading), || info!("Exiting Loading state"));
app.add_systems(OnEnter(GameState::Playing), || info!("Entered Playing state"));
}
}
fn transition_to_playing(mut next_state: ResMut<NextState<GameState>>) {
info!("Transitioning from Loading to Playing state");
next_state.set(GameState::Playing);
}
/// Hides the loading indicator when transitioning to the Playing state
#[cfg(target_arch = "wasm32")]
fn hide_loading_indicator() {