**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"
version = "0.1.0"
dependencies = [
"base-map",
"bevy",
"bevy_asset_loader",
"bevy_flurx",

View File

@@ -1,18 +1,15 @@
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 MapNext, {type Geolocation} from "@/MapNext.tsx";
import { getNeumorphicStyle, getNeumorphicColors } from './theme/neumorphic-theme';
import {layers, LayerSelector} from "@/LayerSelector.tsx";
// public key
const key =
'cGsuZXlKMUlqb2laMlZ2Wm1aelpXVWlMQ0poSWpvaVkycDFOalo0YkdWNk1EUTRjRE41YjJnNFp6VjNNelp6YXlKOS56LUtzS1l0X3VGUGdCSDYwQUFBNFNn';
const layers = [
{ name: 'OSM', value: 'mapbox://styles/mapbox/dark-v11' },
{ name: 'Satellite', value: 'mapbox://styles/mapbox/satellite-v9' },
];
// const vesselLayerStyle: CircleLayerSpecification = {
// id: 'vessel',
@@ -40,8 +37,6 @@ interface VesselStatus {
speed: number;
}
export type Layer = { name: string; value: string };
export type Layers = Layer[];
class MyGeolocation implements Geolocation {
constructor({clearWatch, getCurrentPosition, watchPosition}: {
@@ -65,325 +60,8 @@ class MyGeolocation implements Geolocation {
}
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// 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() {
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [mapView, setMapView] = useState({
longitude: -122.4,
latitude: 37.8,
zoom: 14
});
// 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 selectSearchResult = useCallback(async (searchResult: { lat: string, lon: string }) => {
// Navigate to the selected location with zoom
console.log(`Navigating to: ${searchResult.lat}, ${searchResult.lon}`);
setMapView({
longitude: parseFloat(searchResult.lon),
latitude: parseFloat(searchResult.lat),
zoom: 15
});
}, []);
const handleSearchClick = useCallback(async () => {
if (isSearchOpen && searchInput.length > 1) {
try {
console.log(`Trying to geocode: ${searchInput}`);
const geocode = await fetch('https://geocode.geoffsee.com', {
method: 'POST',
mode: 'cors',
body: JSON.stringify({
location: searchInput,
}),
});
const coordinates = await geocode.json();
const { lat, lon } = coordinates;
console.log(`Got geocode coordinates: ${lat}, ${lon}`);
setSearchResults([{ lat, lon }]);
} catch (e) {
console.error('Geocoding failed:', e);
// Continue without results
}
} else {
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);
}
}
}, [isSearchOpen, searchInput]);
const handleLayerChange = useCallback(async (e: any) => {
const newLayer = layers.find(layer => layer.value === e.target.id);
if (newLayer) {
setSelectedLayer(newLayer);
console.log('Layer changed to:', newLayer.name);
}
}, []);
// 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">
{/* GPS Feed Display — absolutely positioned at bottom-left */}
{vesselPosition && (
<Box
position="absolute"
top={65}
right={4}
zIndex={1}
bg="rgba(0, 0, 0, 0.8)"
color="white"
p={3}
borderRadius="md"
fontSize="sm"
fontFamily="monospace"
minW="200px"
>
<Box fontWeight="bold" mb={2}>GPS Feed</Box>
<Box>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
<Box>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
<Box>Heading: {vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box>
</Box>
)}
{/* 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%"})`}
background="rgba(0, 0, 0, 0.8)"
opacity={isSearchOpen ? 1 : 0}
color="white"
>
<Input
placeholder="Search..."
size="sm"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
color="white"
bg="rgba(0, 0, 0, 0.8)"
border="none"
borderRadius="0"
_focus={{
outline: 'none',
}}
_placeholder={{
color: "#d1cfcf"
}}
/>
{searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
bg="rgba(0, 0, 0, 0.8)"
boxShadow="md"
zIndex={2}
>
{searchResults.map((result, index) => (
<Box
key={index}
p={2}
cursor="pointer"
color="white"
_hover={{ bg: 'whiteAlpha.200' }}
onClick={async () => {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
))}
</Box>
)}
</Box>}
</Box>
<LayerSelector onClick={handleLayerChange} />
</HStack>
<MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={new MyGeolocation({
const custom_geolocation = new MyGeolocation({
clearWatch: (watchId: number) => {
if (typeof window !== 'undefined' && (window as any).geolocationWatches) {
const interval = (window as any).geolocationWatches.get(watchId);
@@ -529,7 +207,285 @@ function App() {
errorCallback(positionError);
}
},
})}/>
});
// interface MapViewParams {
// latitude: number;
// longitude: number;
// zoom: number;
// }
// interface AuthParams {
// authenticated: boolean;
// token: string | null;
// }
function App() {
const { colorMode } = useColorMode();
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [selectedLayer, setSelectedLayer] = useState(layers[0]);
const [searchInput, setSearchInput] = useState('');
const [searchResults, setSearchResults] = useState<any[]>([]);
const [mapView, setMapView] = useState({
longitude: -122.4,
latitude: 37.8,
zoom: 14
});
// 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 selectSearchResult = useCallback(async (searchResult: { lat: string, lon: string }) => {
// Navigate to the selected location with zoom
console.log(`Navigating to: ${searchResult.lat}, ${searchResult.lon}`);
setMapView({
longitude: parseFloat(searchResult.lon),
latitude: parseFloat(searchResult.lat),
zoom: 15
});
}, []);
const handleSearchClick = useCallback(async () => {
if (isSearchOpen && searchInput.length > 1) {
try {
console.log(`Trying to geocode: ${searchInput}`);
const geocode = await fetch('https://geocode.geoffsee.com', {
method: 'POST',
mode: 'cors',
body: JSON.stringify({
location: searchInput,
}),
});
const coordinates = await geocode.json();
const { lat, lon } = coordinates;
console.log(`Got geocode coordinates: ${lat}, ${lon}`);
setSearchResults([{ lat, lon }]);
} catch (e) {
console.error('Geocoding failed:', e);
// Continue without results
}
} else {
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);
}
}
}, [isSearchOpen, searchInput]);
const handleLayerChange = useCallback(async (layer: any) => {
console.log('Layer change requested:', layer);
setSelectedLayer(layer);
console.log('Layer changed to:', layer.name);
}, []);
// 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">
{/* GPS Feed Display — absolutely positioned at top-right */}
{vesselPosition && (
<Box
position="absolute"
top={65}
right={4}
zIndex={1}
p={4}
fontSize="sm"
fontFamily="monospace"
minW="220px"
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
<Box fontWeight="bold" mb={3} fontSize="md">GPS Feed</Box>
<Box mb={1}>Lat: {vesselPosition.latitude.toFixed(6)}°</Box>
<Box mb={1}>Lon: {vesselPosition.longitude.toFixed(6)}°</Box>
<Box mb={1}>Heading: {vesselPosition.heading.toFixed(1)}°</Box>
<Box>Speed: {vesselPosition.speed.toFixed(1)} kts</Box>
</Box>
)}
{/* Button bar — absolutely positioned inside the wrapper */}
<HStack position="absolute" top={4} right={4} zIndex={1}>
<Box
display="flex"
alignItems="center"
position="relative"
>
<Button
size="sm"
variant="surface"
onClick={handleSearchClick}
mr={2}
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
<Text>SEARCH</Text>
</Button>
{isSearchOpen && <Box
w="200px"
transform={`translateX(${isSearchOpen ? "0" : "100%"})`}
opacity={isSearchOpen ? 1 : 0}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
>
<Input
placeholder="Search..."
size="sm"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
border="none"
{...getNeumorphicStyle(colorMode as 'light' | 'dark', 'pressed')}
/>
{searchResults.length > 0 && (
<Box
position="absolute"
top="100%"
left={0}
w="200px"
zIndex={2}
mt={2}
backdropFilter="blur(10px)"
{...getNeumorphicStyle(colorMode as 'light' | 'dark')}
>
{searchResults.map((result, index) => {
const colors = getNeumorphicColors(colorMode as 'light' | 'dark');
return (
<Box
key={index}
p={3}
cursor="pointer"
borderRadius="8px"
transition="all 0.2s ease-in-out"
_hover={{
bg: colors.accent + '20',
transform: 'translateY(-1px)',
}}
onClick={async () => {
console.log(`Selecting result ${result.lat}, ${result.lon}`);
await selectSearchResult(result);
setSearchResults([]);
setIsSearchOpen(false);
}}
>
{`${result.lat}, ${result.lon}`}
</Box>
);
})}
</Box>
)}
</Box>}
</Box>
<LayerSelector onClick={handleLayerChange} />
</HStack>
<MapNext mapboxPublicKey={atob(key)} vesselPosition={vesselPosition} layer={selectedLayer} mapView={mapView} geolocation={window.navigator.geolocation || custom_geolocation}/>
{/*<Map*/}
{/* mapboxAccessToken={atob(key)}*/}
{/* 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 {Box} from "@chakra-ui/react";
import { useColorMode } from './components/ui/color-mode';
import { getNeumorphicStyle, getNeumorphicColors } from './theme/neumorphic-theme';
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) {
const { colorMode } = useColorMode();
const [popupInfo, setPopupInfo] = useState(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);
@@ -230,28 +233,45 @@ export default function MapNext(props: any = {mapboxPublicKey: "", geolocation:
latitude={vesselPopupInfo.latitude}
onClose={() => setVesselPopupInfo(null)}
>
<div style={{ minWidth: '200px' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '16px', fontWeight: 'bold' }}>
<Box
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}
</h3>
<div style={{ fontSize: '14px', lineHeight: '1.4' }}>
<div><strong>Type:</strong> {vesselPopupInfo.type}</div>
<div><strong>MMSI:</strong> {vesselPopupInfo.mmsi}</div>
<div><strong>Call Sign:</strong> {vesselPopupInfo.callSign}</div>
<div><strong>Speed:</strong> {vesselPopupInfo.speed.toFixed(1)} knots</div>
<div><strong>Heading:</strong> {vesselPopupInfo.heading.toFixed(0)}°</div>
<div><strong>Length:</strong> {vesselPopupInfo.length.toFixed(0)}m</div>
</Box>
<Box fontSize="sm" lineHeight="1.6">
<Box mb={1}><strong>Type:</strong> {vesselPopupInfo.type}</Box>
<Box mb={1}><strong>MMSI:</strong> {vesselPopupInfo.mmsi}</Box>
<Box mb={1}><strong>Call Sign:</strong> {vesselPopupInfo.callSign}</Box>
<Box mb={1}><strong>Speed:</strong> {vesselPopupInfo.speed.toFixed(1)} knots</Box>
<Box mb={1}><strong>Heading:</strong> {vesselPopupInfo.heading.toFixed(0)}°</Box>
<Box mb={1}><strong>Length:</strong> {vesselPopupInfo.length.toFixed(0)}m</Box>
{vesselPopupInfo.destination && (
<div><strong>Destination:</strong> {vesselPopupInfo.destination}</div>
<Box mb={1}><strong>Destination:</strong> {vesselPopupInfo.destination}</Box>
)}
{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()}
</div>
</div>
</div>
</Box>
</Box>
</Box>
</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()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
setupFiles: ['./test/test-setup.ts'],
globals: true,
},
});

View File

@@ -100,4 +100,4 @@ console_error_panic_hook = "0.1"
[build-dependencies]
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 {
normal: 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 {
fn default() -> Self {
ButtonColors {
normal: Color::linear_rgb(0.15, 0.15, 0.15),
hovered: Color::linear_rgb(0.25, 0.25, 0.25),
normal: NeumorphicColors::PRIMARY_NORMAL,
hovered: NeumorphicColors::PRIMARY_HOVERED,
pressed: NeumorphicColors::PRIMARY_PRESSED,
}
}
}
@@ -34,6 +59,18 @@ struct Menu;
fn setup_menu(mut commands: Commands) {
info!("menu");
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
.spawn((
Node {
@@ -52,23 +89,27 @@ fn setup_menu(mut commands: Commands) {
.spawn((
Button,
Node {
width: Val::Px(140.0),
height: Val::Px(50.0),
width: Val::Px(180.0),
height: Val::Px(65.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(2.0)),
margin: UiRect::all(Val::Px(8.0)),
..Default::default()
},
BackgroundColor(button_colors.normal),
BorderColor(Color::linear_rgb(0.82, 0.84, 0.87)),
BorderRadius::all(Val::Px(16.0)),
button_colors,
ChangeState(GameState::Playing),
))
.with_child((
Text::new("Play"),
Text::new("▶ PLAY"),
TextFont {
font_size: 40.0,
font_size: 28.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
TextColor(NeumorphicColors::TEXT_PRIMARY),
));
});
commands
@@ -85,74 +126,71 @@ fn setup_menu(mut commands: Commands) {
Menu,
))
.with_children(|children| {
let secondary_button_colors = ButtonColors {
normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
};
children
.spawn((
Button,
Node {
width: Val::Px(170.0),
height: Val::Px(50.0),
justify_content: JustifyContent::SpaceAround,
width: Val::Px(180.0),
height: Val::Px(45.0),
justify_content: JustifyContent::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),
ButtonColors {
normal: Color::NONE,
..default()
},
BackgroundColor(secondary_button_colors.normal),
BorderColor(Color::linear_rgb(0.80, 0.82, 0.85)),
BorderRadius::all(Val::Px(12.0)),
secondary_button_colors,
OpenLink("https://bevyengine.org"),
))
.with_children(|parent| {
parent.spawn((
Text::new("Made with Bevy"),
.with_child((
Text::new("🚀 Made with Bevy"),
TextFont {
font_size: 15.0,
font_size: 14.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
TextColor(NeumorphicColors::TEXT_SECONDARY),
));
parent.spawn((
Node {
width: Val::Px(32.),
..default()
},
));
});
children
.spawn((
Button,
Node {
width: Val::Px(170.0),
height: Val::Px(50.0),
justify_content: JustifyContent::SpaceAround,
width: Val::Px(180.0),
height: Val::Px(45.0),
justify_content: JustifyContent::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()
},
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 {
normal: Color::NONE,
hovered: Color::linear_rgb(0.25, 0.25, 0.25),
normal: NeumorphicColors::SECONDARY_NORMAL,
hovered: NeumorphicColors::SECONDARY_HOVERED,
pressed: NeumorphicColors::SECONDARY_PRESSED,
},
OpenLink("https://github.com/NiklasEi/bevy_game_template"),
))
.with_children(|parent| {
parent.spawn((
Text::new("Open source"),
.with_child((
Text::new("📖 Open Source"),
TextFont {
font_size: 15.0,
font_size: 14.0,
..default()
},
TextColor(Color::linear_rgb(0.9, 0.9, 0.9)),
TextColor(NeumorphicColors::TEXT_SECONDARY),
));
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 {
match *interaction {
Interaction::Pressed => {
// Apply pressed state visual feedback
*color = button_colors.pressed.into();
// Handle button actions
if let Some(state) = change_state {
next_state.set(state.0.clone());
} else if let Some(link) = open_link {
@@ -187,9 +229,11 @@ fn click_play_button(
}
}
Interaction::Hovered => {
// Smooth transition to hovered state
*color = button_colors.hovered.into();
}
Interaction::None => {
// Return to normal state
*color = button_colors.normal.into();
}
}