**Introduce Neumorphic UI design with new themes and styles**

- Added NeumorphicTheme implementation for light and dark modes.
- Refactored `LayerSelector` and `MapNext` components to use the neumorphic style and color utilities.
- Updated `menu.rs` with neumorphic-inspired button and background styling.
- Enhanced GPS feed and vessel popups with neumorphic visuals, improving clarity and aesthetics.
- Temporarily disabled base-map dependency in `yachtpit` for isolation testing.
This commit is contained in:
geoffsee
2025-07-21 16:19:42 -04:00
parent 7528b2117b
commit 82b99b5eab
8 changed files with 506 additions and 321 deletions

1
Cargo.lock generated
View File

@@ -9425,7 +9425,6 @@ checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7"
name = "yachtpit" name = "yachtpit"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base-map",
"bevy", "bevy",
"bevy_asset_loader", "bevy_asset_loader",
"bevy_flurx", "bevy_flurx",

View File

@@ -1,18 +1,15 @@
import 'mapbox-gl/dist/mapbox-gl.css'; import 'mapbox-gl/dist/mapbox-gl.css';
import {Box, Button, HStack, Input} from '@chakra-ui/react'; import {Box, Button, HStack, Input, Text} from '@chakra-ui/react';
import { useColorMode } from './components/ui/color-mode';
import {useCallback, useEffect, useState} from "react"; import {useCallback, useEffect, useState} from "react";
import MapNext, {type Geolocation} from "@/MapNext.tsx"; import MapNext, {type Geolocation} from "@/MapNext.tsx";
import { getNeumorphicStyle, getNeumorphicColors } from './theme/neumorphic-theme';
import {layers, LayerSelector} from "@/LayerSelector.tsx";
// public key // public key
const key = const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn'; 'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
const layers = [
{ name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
];
// const vesselLayerStyle: CircleLayerSpecification = { // const vesselLayerStyle: CircleLayerSpecification = {
// id: 'vessel', // id: 'vessel',
@@ -40,8 +37,6 @@ interface VesselStatus {
speed: number; speed: number;
} }
export type Layer = { name: string; value: string };
export type Layers = Layer[];
class MyGeolocation implements Geolocation { class MyGeolocation implements Geolocation {
constructor({clearWatch, getCurrentPosition, watchPosition}: { constructor({clearWatch, getCurrentPosition, watchPosition}: {
@@ -65,6 +60,155 @@ class MyGeolocation implements Geolocation {
} }
const custom_geolocation = new MyGeolocation({
clearWatch: (watchId: number) => {
if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
const interval = (window as any).geolocationWatches.get(watchId);
if (interval) {
clearInterval(interval);
(window as any).geolocationWatches.delete(watchId);
}
}
},
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => {
if (typeof window === 'undefined') return 0;
// Initialize watches map if it doesn't exist
if (!(window as any).geolocationWatches) {
(window as any).geolocationWatches = new Map();
}
if (!(window as any).geolocationWatchId) {
(window as any).geolocationWatchId = 0;
}
const watchId = ++(window as any).geolocationWatchId;
const pollPosition = async () => {
if ((window as any).__FLURX__) {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
}
};
// Poll immediately and then at intervals
pollPosition();
const interval = setInterval(pollPosition, options?.timeout || 5000);
(window as any).geolocationWatches.set(watchId, interval);
return watchId;
},
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, _options?: PositionOptions) => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
(async () => {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
})();
} else if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: '__FLURX__ not available',
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
},
});
// interface MapViewParams { // interface MapViewParams {
// latitude: number; // latitude: number;
// longitude: number; // longitude: number;
@@ -76,49 +220,10 @@ class MyGeolocation implements Geolocation {
// token: string | null; // token: string | null;
// } // }
function LayerSelector(props: { onClick: (e: any) => Promise<void> }) {
const [isOpen, setIsOpen] = useState(false);
return (
<Box position="relative">
<Button colorScheme="blue" size="sm" variant="solid" onClick={() => setIsOpen(!isOpen)}>
Layer
</Button>
{isOpen && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="rgba(0, 0, 0, 0.8)"
boxShadow="md"
zIndex={2}
>
{layers.map(layer => (
<Box
key={layer.value}
id={layer.value}
p={2}
cursor="pointer"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async e => {
setIsOpen(false);
await props.onClick(e);
}}
>
{layer.name}
</Box>
))}
</Box>
)}
</Box>
);
}
function App() { function App() {
const { colorMode } = useColorMode();
const [isSearchOpen, setIsSearchOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]); const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState(''); const [searchInput, setSearchInput] = useState('');
@@ -215,12 +320,10 @@ function App() {
} }
}, [isSearchOpen, searchInput]); }, [isSearchOpen, searchInput]);
const handleLayerChange = useCallback(async (e: any) => { const handleLayerChange = useCallback(async (layer: any) => {
const newLayer = layers.find(layer => layer.value === e.target.id); console.log('Layer change requested:', layer);
if (newLayer) { setSelectedLayer(layer);
setSelectedLayer(newLayer); console.log('Layer changed to:', layer.name);
console.log('Layer changed to:', newLayer.name);
}
}, []); }, []);
// const handleMapViewChange = useCallback(async (evt: any) => { // const handleMapViewChange = useCallback(async (evt: any) => {
@@ -289,25 +392,24 @@ function App() {
return ( return (
/* Full-screen wrapper — fills the viewport and becomes the positioning context */ /* Full-screen wrapper — fills the viewport and becomes the positioning context */
<Box w="100vw" h="100vh" position="relative" overflow="hidden"> <Box w="100vw" h="100vh" position="relative" overflow="hidden">
{/* GPS Feed Display — absolutely positioned at bottom-left */} {/* GPS Feed Display — absolutely positioned at top-right */}
{vesselPosition && ( {vesselPosition && (
<Box <Box
position="absolute" position="absolute"
top={65} top={65}
right={4} right={4}
zIndex={1} zIndex={1}
bg="rgba(0, 0, 0, 0.8)" p={4}
color="white"
p={3}
borderRadius="md"
fontSize="sm" fontSize="sm"
fontFamily="monospace" fontFamily="monospace"
minW="200px" minW="220px"
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
> >
<Box fontWeight="bold" mb={2}>GPS Feed</Box> <Box fontWeight="bold" mb={3} fontSize="md">GPS Feed</Box>
<Box>Lat: {vesselPosition.latitude.toFixed(6)}°</Box> <Box mb={1}>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
<Box>Lon: {vesselPosition.longitude.toFixed(6)}°</Box> <Box mb={1}>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
<Box>Heading: {vesselPosition.heading.toFixed(1)}°</Box> <Box mb={1}>Heading: {vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box> <Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box>
</Box> </Box>
)} )}
@@ -316,39 +418,31 @@ function App() {
<Box <Box
display="flex" display="flex"
alignItems="center" alignItems="center"
position="relative"
> >
<Button <Button
colorScheme="teal"
size="sm" size="sm"
variant="solid" variant="surface"
onClick={handleSearchClick} onClick={handleSearchClick}
mr={2} mr={2}
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
> >
Search <Text>SEARCH</Text>
</Button> </Button>
{isSearchOpen && <Box {isSearchOpen && <Box
w="200px" w="200px"
transition="all 0.3s"
transform={`translateX(${isSearchOpen ? "0" : "100%"})`} transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
background="rgba(0, 0, 0, 0.8)"
opacity={isSearchOpen ? 1 : 0} opacity={isSearchOpen ? 1 : 0}
color="white" backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
> >
<Input <Input
placeholder="Search..." placeholder="Search..."
size="sm" size="sm"
value={searchInput} value={searchInput}
onChange={e => setSearchInput(e.target.value)} onChange={e => setSearchInput(e.target.value)}
color="white"
bg="rgba(0, 0, 0, 0.8)"
border="none" border="none"
borderRadius="0" {...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
_focus={{
outline: 'none',
}}
_placeholder={{
color: "#d1cfcf"
}}
/> />
{searchResults.length > 0 && ( {searchResults.length > 0 && (
<Box <Box
@@ -356,180 +450,42 @@ function App() {
top="100%" top="100%"
left={0} left={0}
w="200px" w="200px"
bg="rgba(0, 0, 0, 0.8)"
boxShadow="md"
zIndex={2} zIndex={2}
mt={2}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
> >
{searchResults.map((result, index) => ( {searchResults.map((result, index) => {
<Box const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
key={index} return (
p={2} <Box
cursor="pointer" key={index}
color="white" p={3}
_hover={{ bg: 'whiteAlpha.200' }} cursor="pointer"
onClick={async () => { borderRadius="8px"
console.log(`Selecting result ${result.lat}, ${result.lon}`); transition="all 0.2s ease-in-out"
await selectSearchResult(result); _hover={{
setSearchResults([]); bg: colors.accent + '20',
setIsSearchOpen(false); transform: 'translateY(-1px)',
}} }}
> onClick={async () => {
{`${result.lat}, ${result.lon}`} console.log(`Selecting result ${result.lat}, ${result.lon}`);
</Box> await selectSearchResult(result);
))} setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
);
})}
</Box> </Box>
)} )}
</Box>} </Box>}
</Box> </Box>
<LayerSelector onClick={handleLayerChange} /> <LayerSelector onClick={handleLayerChange} />
</HStack> </HStack>
<MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={new MyGeolocation({ <MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={window.navigator.geolocation || custom_geolocation}/>
clearWatch: (watchId: number) => {
if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
const interval = (window as any).geolocationWatches.get(watchId);
if (interval) {
clearInterval(interval);
(window as any).geolocationWatches.delete(watchId);
}
}
},
watchPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, options?: PositionOptions) => {
if (typeof window === 'undefined') return 0;
// Initialize watches map if it doesn't exist
if (!(window as any).geolocationWatches) {
(window as any).geolocationWatches = new Map();
}
if (!(window as any).geolocationWatchId) {
(window as any).geolocationWatchId = 0;
}
const watchId = ++(window as any).geolocationWatchId;
const pollPosition = async () => {
if ((window as any).__FLURX__) {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
}
};
// Poll immediately and then at intervals
pollPosition();
const interval = setInterval(pollPosition, options?.timeout || 5000);
(window as any).geolocationWatches.set(watchId, interval);
return watchId;
},
getCurrentPosition: (successCallback: PositionCallback, errorCallback?: PositionErrorCallback | null, _options?: PositionOptions) => {
if (typeof window !== 'undefined' && (window as any).__FLURX__) {
(async () => {
try {
const vesselStatus: VesselStatus = await (window as any).__FLURX__.invoke("get_vessel_status");
const position: GeolocationPosition = {
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10, // Assume 10m accuracy
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed,
toJSON: () => ({
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
})
},
timestamp: Date.now(),
toJSON: () => ({
coords: {
latitude: vesselStatus.latitude,
longitude: vesselStatus.longitude,
altitude: null,
accuracy: 10,
altitudeAccuracy: null,
heading: vesselStatus.heading,
speed: vesselStatus.speed
},
timestamp: Date.now()
})
};
successCallback(position);
} catch (error) {
if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: 'Failed to get vessel status: ' + error,
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
}
})();
} else if (errorCallback) {
const positionError: GeolocationPositionError = {
code: 2, // POSITION_UNAVAILABLE
message: '__FLURX__ not available',
PERMISSION_DENIED: 1,
POSITION_UNAVAILABLE: 2,
TIMEOUT: 3
};
errorCallback(positionError);
}
},
})}/>
{/*<Map*/} {/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/} {/* mapboxAccessToken={atob(key)}*/}
{/* initialViewState={mapView}*/} {/* initialViewState={mapView}*/}

View File

@@ -0,0 +1,91 @@
import 'mapbox-gl/dist/mapbox-gl.css';
import {Button, Menu, Portal} from '@chakra-ui/react';
import {useColorMode} from './components/ui/color-mode';
import {useState} from "react";
import {getNeumorphicColors, getNeumorphicStyle} from './theme/neumorphic-theme';
export const layers = [
{ name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
];
// const vesselLayerStyle: CircleLayerSpecification = {
// id: 'vessel',
// type: 'circle',
// paint: {
// 'circle-radius': 8,
// 'circle-color': '#ff4444',
// 'circle-stroke-width': 2,
// 'circle-stroke-color': '#ffffff'
// },
// source: ''
// };
export type Layer = { name: string; value: string };
export type Layers = Layer[];
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// token: string | null;
// }
export function LayerSelector(props: { onClick: (layer: Layer) => Promise<void> }) {
const { colorMode } = useColorMode();
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const neumorphicStyle = getNeumorphicStyle(colorMode as 'light' | 'dark');
const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
return (
<Menu.Root>
<Menu.Trigger asChild>
<Button
size="sm"
variant="surface"
{...neumorphicStyle}
>
{selectedLayer?.name || 'Layer'}
</Button>
</Menu.Trigger>
<Portal>
<Menu.Positioner>
<Menu.Content
minW="200px"
py={2}
{...neumorphicStyle}
>
{layers.map(layer => (
<Menu.Item
key={layer.value}
id={layer.value}
value={layer.value}
borderRadius={6}
transition="all 0.2s ease-in-out"
_hover={{
bg: colors.accent + '20',
transform: 'translateY(-1px)',
}}
onClick={(e) => {
// @ts-ignore
console.log(e.target.id)
setSelectedLayer(layer);
props.onClick(layer);
}}
>
{layer.name}
</Menu.Item>
))}
</Menu.Content>
</Menu.Positioner>
</Portal>
</Menu.Root>
);
}

View File

@@ -17,6 +17,8 @@ import { useRealAISProvider } from './real-ais-provider.tsx';
import PORTS from './test_data/nautical-base-data.json'; import PORTS from './test_data/nautical-base-data.json';
import {Box} from "@chakra-ui/react"; import {Box} from "@chakra-ui/react";
import { useColorMode } from './components/ui/color-mode';
import { getNeumorphicStyle, getNeumorphicColors } from './theme/neumorphic-theme';
export interface Geolocation { export interface Geolocation {
@@ -31,6 +33,7 @@ export interface Geolocation {
export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) { export default function MapNext(props: any = {mapboxPublicKey: "", geolocation: Geolocation, vesselPosition: undefined, layer: undefined, mapView: undefined} as any) {
const { colorMode } = useColorMode();
const [popupInfo, setPopupInfo] = useState(null); const [popupInfo, setPopupInfo] = useState(null);
const [vesselPopupInfo, setVesselPopupInfo] = useState<VesselData | null>(null); const [vesselPopupInfo, setVesselPopupInfo] = useState<VesselData | null>(null);
const [boundingBox, setBoundingBox] = useState<{sw_lat: number, sw_lon: number, ne_lat: number, ne_lon: number} | undefined>(undefined); const [boundingBox, setBoundingBox] = useState<{sw_lat: number, sw_lon: number, ne_lat: number, ne_lon: number} | undefined>(undefined);
@@ -230,28 +233,45 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
latitude={vesselPopupInfo.latitude} latitude={vesselPopupInfo.latitude}
onClose={() => setVesselPopupInfo(null)} onClose={() => setVesselPopupInfo(null)}
> >
<div style={{ minWidth: '200px' }}> <Box
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 'bold' }}> minW="240px"
p={4}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
<Box
mb={3}
fontSize="lg"
fontWeight="bold"
color={getNeumorphicColors(colorMode as 'light' | 'dark').text}
>
{vesselPopupInfo.name} {vesselPopupInfo.name}
</h3> </Box>
<div style={{ fontSize: '14px', lineHeight: '1.4' }}> <Box fontSize="sm" lineHeight="1.6">
<div><strong>Type:</strong> {vesselPopupInfo.type}</div> <Box mb={1}><strong>Type:</strong> {vesselPopupInfo.type}</Box>
<div><strong>MMSI:</strong> {vesselPopupInfo.mmsi}</div> <Box mb={1}><strong>MMSI:</strong> {vesselPopupInfo.mmsi}</Box>
<div><strong>Call Sign:</strong> {vesselPopupInfo.callSign}</div> <Box mb={1}><strong>Call Sign:</strong> {vesselPopupInfo.callSign}</Box>
<div><strong>Speed:</strong> {vesselPopupInfo.speed.toFixed(1)} knots</div> <Box mb={1}><strong>Speed:</strong> {vesselPopupInfo.speed.toFixed(1)} knots</Box>
<div><strong>Heading:</strong> {vesselPopupInfo.heading.toFixed(0)}°</div> <Box mb={1}><strong>Heading:</strong> {vesselPopupInfo.heading.toFixed(0)}°</Box>
<div><strong>Length:</strong> {vesselPopupInfo.length.toFixed(0)}m</div> <Box mb={1}><strong>Length:</strong> {vesselPopupInfo.length.toFixed(0)}m</Box>
{vesselPopupInfo.destination && ( {vesselPopupInfo.destination && (
<div><strong>Destination:</strong> {vesselPopupInfo.destination}</div> <Box mb={1}><strong>Destination:</strong> {vesselPopupInfo.destination}</Box>
)} )}
{vesselPopupInfo.eta && ( {vesselPopupInfo.eta && (
<div><strong>ETA:</strong> {vesselPopupInfo.eta}</div> <Box mb={1}><strong>ETA:</strong> {vesselPopupInfo.eta}</Box>
)} )}
<div style={{ fontSize: '12px', color: '#666', marginTop: '8px' }}> <Box
fontSize="xs"
color={getNeumorphicColors(colorMode as 'light' | 'dark').textSecondary}
mt={3}
pt={2}
borderTop="1px solid"
borderColor={getNeumorphicColors(colorMode as 'light' | 'dark').textSecondary + '30'}
>
Last Update: {vesselPopupInfo.lastUpdate.toLocaleTimeString()} Last Update: {vesselPopupInfo.lastUpdate.toLocaleTimeString()}
</div> </Box>
</div> </Box>
</div> </Box>
</Popup> </Popup>
)} )}

View File

@@ -0,0 +1,75 @@
import { defineConfig } from "@chakra-ui/react";
// Neumorphic color palette
const neumorphicColors = {
light: {
bg: '#e0e5ec',
surface: '#e0e5ec',
text: '#2d3748',
textSecondary: '#4a5568',
accent: '#3182ce',
shadow: {
dark: '#a3b1c6',
light: '#ffffff',
},
},
dark: {
bg: '#2d3748',
surface: '#ffffff',
text: '#f7fafc',
textSecondary: '#e2e8f0',
accent: '#63b3ed',
shadow: {
dark: '#1a202c',
light: '#4a5568',
},
},
};
// Neumorphic shadow mixins
const neumorphicShadows = {
light: {
raised: '1px 1px 2px #a3b1c6, -1px -1px 2px #ffffff',
pressed: 'inset 2px 2px 4px #a3b1c6, inset -2px -2px 4px #ffffff',
subtle: '1px 1px 2px #a3b1c6, -1px -1px 2px #ffffff',
subtlePressed: 'inset 1px 1px 2px #a3b1c6, inset -1px -1px 2px #ffffff',
floating: '6px 6px 12px #a3b1c6, -6px -6px 12px #ffffff',
},
dark: {
raised: '2px 2px 4px #1a202c, -2px -2px 4px #4a5568',
pressed: 'inset 2px 2px 4px #1a202c, inset -2px -2px 4px #4a5568',
subtle: '2px 2px 2px #1a202c, -2px -2px 2px #4a5568',
subtlePressed: 'inset 2px 2px 2px #1a202c, inset -2px -2px 2px #4a5568',
floating: '6px 6px 12px #1a202c, -6px -6px 12px #4a5568',
},
};
// Simplified theme configuration to avoid TypeScript errors
// The utility functions below provide the neumorphic styling functionality
export const neumorphicTheme = defineConfig({
theme: {
// Theme configuration simplified to avoid type errors
},
});
// Utility functions for neumorphic styling
export const getNeumorphicStyle = (colorMode: 'light' | 'dark', variant: 'raised' | 'pressed' | 'subtle' | 'floating' = 'raised') => {
const colors = neumorphicColors[colorMode];
const shadows = neumorphicShadows[colorMode];
return {
bg: colors.surface,
color: colors.text,
borderRadius: 6,
boxShadow: shadows[variant] || shadows.raised,
transition: 'all 0.3s ease-in-out',
};
};
export const getNeumorphicColors = (colorMode: 'light' | 'dark') => {
return neumorphicColors[colorMode];
};
export const getNeumorphicShadows = (colorMode: 'light' | 'dark') => {
return neumorphicShadows[colorMode];
};

View File

@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()], plugins: [react()],
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'], setupFiles: ['./test/test-setup.ts'],
globals: true, globals: true,
}, },
}); });

View File

@@ -100,4 +100,4 @@ console_error_panic_hook = "0.1"
[build-dependencies] [build-dependencies]
embed-resource = "1" embed-resource = "1"
base-map = { path = "../base-map" } # base-map = { path = "../base-map" } # Temporarily disabled for testing

View File

@@ -13,17 +13,42 @@ impl Plugin for MenuPlugin {
} }
} }
#[derive(Component)] #[derive(Component, Clone)]
struct ButtonColors { struct ButtonColors {
normal: Color, normal: Color,
hovered: Color, hovered: Color,
pressed: Color,
}
// Neumorphic color palette for luxury design
struct NeumorphicColors;
impl NeumorphicColors {
// Base surface color - soft gray with warm undertones
const SURFACE: Color = Color::linear_rgb(0.88, 0.90, 0.92);
// Primary button colors with depth
const PRIMARY_NORMAL: Color = Color::linear_rgb(0.85, 0.87, 0.90);
const PRIMARY_HOVERED: Color = Color::linear_rgb(0.90, 0.92, 0.95);
const PRIMARY_PRESSED: Color = Color::linear_rgb(0.80, 0.82, 0.85);
// Secondary button colors (more subtle)
const SECONDARY_NORMAL: Color = Color::linear_rgb(0.86, 0.88, 0.91);
const SECONDARY_HOVERED: Color = Color::linear_rgb(0.88, 0.90, 0.93);
const SECONDARY_PRESSED: Color = Color::linear_rgb(0.82, 0.84, 0.87);
// Text colors for contrast
const TEXT_PRIMARY: Color = Color::linear_rgb(0.25, 0.30, 0.35);
const TEXT_SECONDARY: Color = Color::linear_rgb(0.45, 0.50, 0.55);
const TEXT_ACCENT: Color = Color::linear_rgb(0.20, 0.45, 0.75);
} }
impl Default for ButtonColors { impl Default for ButtonColors {
fn default() -> Self { fn default() -> Self {
ButtonColors { ButtonColors {
normal: Color::linear_rgb(0.15, 0.15, 0.15), normal: NeumorphicColors::PRIMARY_NORMAL,
hovered: Color::linear_rgb(0.25, 0.25, 0.25), hovered: NeumorphicColors::PRIMARY_HOVERED,
pressed: NeumorphicColors::PRIMARY_PRESSED,
} }
} }
} }
@@ -34,6 +59,18 @@ struct Menu;
fn setup_menu(mut commands: Commands) { fn setup_menu(mut commands: Commands) {
info!("menu"); info!("menu");
commands.spawn((Camera2d, Msaa::Off)); commands.spawn((Camera2d, Msaa::Off));
// Set neumorphic background
commands.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
position_type: PositionType::Absolute,
..default()
},
BackgroundColor(NeumorphicColors::SURFACE),
));
commands commands
.spawn(( .spawn((
Node { Node {
@@ -52,23 +89,27 @@ fn setup_menu(mut commands: Commands) {
.spawn(( .spawn((
Button, Button,
Node { Node {
width: Val::Px(140.0), width: Val::Px(180.0),
height: Val::Px(50.0), height: Val::Px(65.0),
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(2.0)),
margin: UiRect::all(Val::Px(8.0)),
..Default::default() ..Default::default()
}, },
BackgroundColor(button_colors.normal), BackgroundColor(button_colors.normal),
BorderColor(Color::linear_rgb(0.82, 0.84, 0.87)),
BorderRadius::all(Val::Px(16.0)),
button_colors, button_colors,
ChangeState(GameState::Playing), ChangeState(GameState::Playing),
)) ))
.with_child(( .with_child((
Text::new("Play"), Text::new("▶ PLAY"),
TextFont { TextFont {
font_size: 40.0, font_size: 28.0,
..default() ..default()
}, },
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)), TextColor(NeumorphicColors::TEXT_PRIMARY),
)); ));
}); });
commands commands
@@ -85,74 +126,71 @@ fn setup_menu(mut commands: Commands) {
Menu, Menu,
)) ))
.with_children(|children| { .with_children(|children| {
let secondary_button_colors = ButtonColors {
normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
};
children children
.spawn(( .spawn((
Button, Button,
Node { Node {
width: Val::Px(170.0), width: Val::Px(180.0),
height: Val::Px(50.0), height: Val::Px(45.0),
justify_content: JustifyContent::SpaceAround, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(5.)), padding: UiRect::all(Val::Px(8.)),
border: UiRect::all(Val::Px(1.0)),
margin: UiRect::horizontal(Val::Px(8.0)),
..Default::default() ..Default::default()
}, },
BackgroundColor(Color::NONE), BackgroundColor(secondary_button_colors.normal),
ButtonColors { BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
normal: Color::NONE, BorderRadius::all(Val::Px(12.0)),
..default() secondary_button_colors,
},
OpenLink("https://bevyengine.org"), OpenLink("https://bevyengine.org"),
)) ))
.with_children(|parent| { .with_child((
parent.spawn(( Text::new("🚀 Made with Bevy"),
Text::new("Made with Bevy"), TextFont {
TextFont { font_size: 14.0,
font_size: 15.0, ..default()
..default() },
}, TextColor(NeumorphicColors::TEXT_SECONDARY),
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)), ));
));
parent.spawn((
Node {
width: Val::Px(32.),
..default()
},
));
});
children children
.spawn(( .spawn((
Button, Button,
Node { Node {
width: Val::Px(170.0), width: Val::Px(180.0),
height: Val::Px(50.0), height: Val::Px(45.0),
justify_content: JustifyContent::SpaceAround, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(5.)), padding: UiRect::all(Val::Px(8.)),
border: UiRect::all(Val::Px(1.0)),
margin: UiRect::horizontal(Val::Px(8.0)),
..default() ..default()
}, },
BackgroundColor(Color::NONE), BackgroundColor(secondary_button_colors.normal),
BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
BorderRadius::all(Val::Px(12.0)),
ButtonColors { ButtonColors {
normal: Color::NONE, normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: Color::linear_rgb(0.25, 0.25, 0.25), hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
}, },
OpenLink("https://github.com/NiklasEi/bevy_game_template"), OpenLink("https://github.com/NiklasEi/bevy_game_template"),
)) ))
.with_children(|parent| { .with_child((
parent.spawn(( Text::new("📖 Open Source"),
Text::new("Open source"), TextFont {
TextFont { font_size: 14.0,
font_size: 15.0, ..default()
..default() },
}, TextColor(NeumorphicColors::TEXT_SECONDARY),
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)), ));
));
parent.spawn((
Node {
width: Val::Px(32.),
..default()
},
));
});
}); });
} }
@@ -178,6 +216,10 @@ fn click_play_button(
for (interaction, mut color, button_colors, change_state, open_link) in &mut interaction_query { for (interaction, mut color, button_colors, change_state, open_link) in &mut interaction_query {
match *interaction { match *interaction {
Interaction::Pressed => { Interaction::Pressed => {
// Apply pressed state visual feedback
*color = button_colors.pressed.into();
// Handle button actions
if let Some(state) = change_state { if let Some(state) = change_state {
next_state.set(state.0.clone()); next_state.set(state.0.clone());
} else if let Some(link) = open_link { } else if let Some(link) = open_link {
@@ -187,9 +229,11 @@ fn click_play_button(
} }
} }
Interaction::Hovered => { Interaction::Hovered => {
// Smooth transition to hovered state
*color = button_colors.hovered.into(); *color = button_colors.hovered.into();
} }
Interaction::None => { Interaction::None => {
// Return to normal state
*color = button_colors.normal.into(); *color = button_colors.normal.into();
} }
} }