diff --git a/app/src/gui/components/map/center-control.tsx b/app/src/gui/components/map/center-control.tsx index 1fefb5ae1..2a51fcd18 100644 --- a/app/src/gui/components/map/center-control.tsx +++ b/app/src/gui/components/map/center-control.tsx @@ -8,12 +8,12 @@ import src from '../../../target.svg'; * Creates a custom control button that centers the map view to a specified coordinate. * * @param {View} view - The map view instance to be controlled. - * @param {Coordinate} center - The coordinate to which the map view should be centered. + * @param {() => void} center - Callback to center the map view. * @returns {Control} - The custom control instance. */ export const createCenterControl = ( view: View, - center: Coordinate + centerMap: () => void ): Control => { const button = document.createElement('button'); button.className = 'ol-center-button'; @@ -28,7 +28,7 @@ export const createCenterControl = ( ); const handleClick = () => { - view.setCenter(center); + centerMap(); }; button.addEventListener('click', handleClick); diff --git a/app/src/gui/components/map/map-component.tsx b/app/src/gui/components/map/map-component.tsx index dcbea7979..f7b87c48a 100644 --- a/app/src/gui/components/map/map-component.tsx +++ b/app/src/gui/components/map/map-component.tsx @@ -13,8 +13,15 @@ * See, the License, for the specific language governing permissions and * limitations under the License. * - * Description: - * Display a map +/** + * MapComponent.tsx + * + * This component renders an interactive OpenLayers map with support for: + * - Real-time GPS tracking (blue dot, accuracy circle, direction triangle) + * - Offline map support using cached tiles + * - Optional fake GPS simulation for browser testing + * - Dynamic center control and zooming + * - Integration with parent component through callback */ import {Box, Grid} from '@mui/material'; @@ -26,17 +33,30 @@ import VectorLayer from 'ol/layer/Vector'; import Map from 'ol/Map'; import {transform} from 'ol/proj'; import VectorSource from 'ol/source/Vector'; -import {Circle, Fill, Stroke, Style} from 'ol/style'; -import {useCallback, useEffect, useMemo, useState} from 'react'; +import {Fill, RegularShape, Stroke, Style} from 'ol/style'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIsOnline} from '../../../utils/customHooks'; import {getCoordinates, useCurrentLocation} from '../../../utils/useLocation'; import {createCenterControl} from '../map/center-control'; import {VectorTileStore} from './tile-source'; +import Feature from 'ol/Feature'; +import {Point} from 'ol/geom'; +import CircleStyle from 'ol/style/Circle'; +import {Geolocation, Position} from '@capacitor/geolocation'; const defaultMapProjection = 'EPSG:3857'; const MAX_ZOOM = 20; const MIN_ZOOM = 12; +/** + * Optional fake GPS mode for browser testing. Enable via: + * `window.__USE_FAKE_GPS__ = true` in browser console. + */ declare global { + interface Window { + __USE_FAKE_GPS__?: boolean; + } +} + /** * canShowMapNear - can we show a map near this location? * @@ -76,13 +96,20 @@ export interface MapComponentProps { zoom?: number; } +/** + * MapComponent + * + * This is the main map rendering component. It: + * - Initializes an OpenLayers map with tile support + * - Renders live GPS location and directional cursor + * - Supports fake GPS simulation via global flag + * - Accepts a parent callback to provide the map instance + */ export const MapComponent = (props: MapComponentProps) => { const [map, setMap] = useState(undefined); const [zoomLevel, setZoomLevel] = useState(props.zoom || MIN_ZOOM); // Default zoom level const [attribution, setAttribution] = useState(null); - const {isOnline} = useIsOnline(); - const tileStore = useMemo(() => new VectorTileStore(), []); // Use the custom hook for location @@ -97,8 +124,13 @@ export const MapComponent = (props: MapComponentProps) => { return getCoordinates(currentPosition); }, [props.center, currentPosition]); + const positionLayerRef = useRef(); + const watchIdRef = useRef(null); + const fakeIntervalRef = useRef(null); + const liveLocationRef = useRef(null); + /** - * Create the OpenLayers map element + * Initializes the map instance with base tile layers and zoom controls. */ const createMap = useCallback(async (element: HTMLElement): Promise => { setAttribution(tileStore.getAttribution() as unknown as string); @@ -132,54 +164,165 @@ export const MapComponent = (props: MapComponentProps) => { }, []); /** - * Add a marker to the map at the current location + * Initializes and updates the live GPS location cursor. + * Displays: + * - Blue dot at user location with directional traingular and accuracy circle. which would wokk on rela-time gps location tracking * - * @param theMap the map element + * Also supports fake GPS simulation for browser testing. */ - const addCurrentLocationMarker = (theMap: Map) => { - const source = new VectorSource(); - const geoJson = new GeoJSON(); + const startLiveCursor = (theMap: Map) => { + // Clean up before re-adding + if (positionLayerRef.current) { + theMap.removeLayer(positionLayerRef.current); + positionLayerRef.current.getSource()?.clear(); + positionLayerRef.current = undefined; + } + if (watchIdRef.current !== null) { + Geolocation.clearWatch({id: watchIdRef.current}).then(() => { + watchIdRef.current = null; + }); + } + // to be removed later after testing @TODO: RG + if (fakeIntervalRef.current !== null) { + clearInterval(fakeIntervalRef.current); + fakeIntervalRef.current = null; + } - const stroke = new Stroke({color: '#e2ebef', width: 2}); - const layer = new VectorLayer({ - source: source, - style: new Style({ - image: new Circle({ - radius: 10, - fill: new Fill({color: '#465ddf90'}), - stroke: stroke, - }), - }), - }); + const view = theMap.getView(); + const projection = view.getProjection(); + const positionSource = new VectorSource(); + const layer = new VectorLayer({source: positionSource, zIndex: 999}); + theMap.addLayer(layer); + positionLayerRef.current = layer; - // only do this if we have a real map_center - if (mapCenter) { - const centerFeature = { - type: 'Feature', - geometry: { - type: 'Point', - coordinates: mapCenter, - }, - }; - - // there is only one feature but readFeature return type is odd and readFeatures works for singletons - const theFeatures = geoJson.readFeatures(centerFeature, { - dataProjection: 'EPSG:4326', - featureProjection: theMap.getView().getProjection(), + const dotFeature = new Feature(new Point([0, 0])); + const triangleFeature = new Feature(new Point([0, 0])); + const accuracyFeature = new Feature(new Point([0, 0])); + positionSource.addFeatures([dotFeature, triangleFeature, accuracyFeature]); + + const updateCursor = ( + coords: number[], + heading: number, + accuracy: number + ) => { + const resolution = view.getResolution() ?? 1; + + // blue location dot + dotFeature.setGeometry(new Point(coords)); + dotFeature.setStyle( + new Style({ + image: new CircleStyle({ + radius: 14, + fill: new Fill({color: '#1a73e8'}), + stroke: new Stroke({color: '#A19F9FFF', width: 3}), + }), + }) + ); + + // accuracy circle + const accuracyRadius = accuracy / resolution; + accuracyFeature.setGeometry(new Point(coords)); + accuracyFeature.setStyle( + new Style({ + image: new CircleStyle({ + radius: accuracyRadius, + fill: new Fill({color: 'rgba(100, 149, 237, 0.1)'}), + stroke: new Stroke({color: 'rgba(100, 149, 237, 0.4)', width: 1}), + }), + }) + ); + + // heading is in degrees + const headingRadians = (heading * Math.PI) / 180; + // directional navigation triangle + triangleFeature.setGeometry(new Point(coords)); + triangleFeature.setStyle( + new Style({ + image: new RegularShape({ + points: 3, + radius: 12, + rotation: headingRadians + Math.PI, + angle: Math.PI, + fill: new Fill({color: '#1a73e8'}), + stroke: new Stroke({color: 'white', width: 2}), + }), + geometry: () => { + const px = theMap.getPixelFromCoordinate(coords); + const offset = 23; + const dx = offset * Math.sin(headingRadians); + const dy = -offset * Math.cos(headingRadians); + const newPx = [px[0] + dx, px[1] + dy]; + return new Point(theMap.getCoordinateFromPixel(newPx)); + }, + }) + ); + }; + + if (!window.__USE_FAKE_GPS__) { + // Add a watch on position, update our live cursor when it changes + + Geolocation.watchPosition({enableHighAccuracy: true}, (position, err) => { + if (err) { + console.error(err); + return; + } + if (position) { + liveLocationRef.current = position; + const coords = transform( + [position.coords.longitude, position.coords.latitude], + 'EPSG:4326', + projection + ); + updateCursor( + coords, + position.coords.heading ?? 0, + position.coords.accuracy ?? 30 + ); + } + }).then(id => { + watchIdRef.current = id; }); - source.addFeature(theFeatures[0]); - theMap.addLayer(layer); + } else { + /** + * Fake GPS Simulation (for browser testing only) + * To activate, run this in browser console: window.__USE_FAKE_GPS__ = true, might work in browser if no maptiler/map issues + */ + console.log('Fake GPS mode ON'); + let angle = 0; + const radius = 0.0001; + const center = transform([151.231, -33.918], 'EPSG:4326', projection); + + if (watchIdRef.current) clearInterval(watchIdRef.current); + + setInterval(() => { + angle += 0.1; + const coords = [ + center[0] + radius * Math.cos(angle), + center[1] + radius * Math.sin(angle), + ]; + updateCursor(coords, angle, 20); + }, 1000); + } + }; + + // center the map on the current location + const centerMap = () => { + if (map && liveLocationRef.current) { + const coords = getCoordinates(liveLocationRef.current); + if (coords) { + const center = transform(coords, 'EPSG:4326', defaultMapProjection); + map.getView().setCenter(center); + } } }; // when we have a location and a map, add the 'here' marker to the map useEffect(() => { if (!loadingLocation && map && mapCenter) { - addCurrentLocationMarker(map); + startLiveCursor(map); if (mapCenter) { const center = transform(mapCenter, 'EPSG:4326', defaultMapProjection); - // add the 'here' button to go to the current location - map.addControl(createCenterControl(map.getView(), center)); + map.addControl(createCenterControl(map.getView(), centerMap)); // we set the map extent if we were given one or if not, // set the map center which will either have been passed @@ -215,6 +358,12 @@ export const MapComponent = (props: MapComponentProps) => { [map, createMap] ); + useEffect(() => { + if (map) { + startLiveCursor(map); //reapply live cursor on map init or remount + } + }, [map]); + return ( <> diff --git a/app/src/gui/fields/maps/MapWrapper.css b/app/src/gui/fields/maps/MapWrapper.css index b4e349460..ddf2e4423 100644 --- a/app/src/gui/fields/maps/MapWrapper.css +++ b/app/src/gui/fields/maps/MapWrapper.css @@ -16,3 +16,32 @@ .mapSubmitButton { grid-row: 2; } +/* Custom map style */ +.gps-location-dot { + width: 20px; + height: 20px; + background: #1a73e8; + border: 2px solid white; + border-radius: 50%; + box-shadow: 0 0 0 rgba(26, 115, 232, 0.4); + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { + transform: scale(0.95); + opacity: 1; + } + 70% { + transform: scale(1.3); + opacity: 0; + } + 100% { + transform: scale(0.95); + opacity: 0; + } +} +.ol-overlay-container, +.ol-overlay-container-stopevent { + display: none !important; +} diff --git a/app/src/gui/fields/maps/MapWrapper.tsx b/app/src/gui/fields/maps/MapWrapper.tsx index 1138db094..1bf9e2e6c 100644 --- a/app/src/gui/fields/maps/MapWrapper.tsx +++ b/app/src/gui/fields/maps/MapWrapper.tsx @@ -27,10 +27,10 @@ import {Draw, Modify} from 'ol/interaction'; import VectorLayer from 'ol/layer/Vector'; import {register} from 'ol/proj/proj4'; import VectorSource from 'ol/source/Vector'; -import {Circle as CircleStyle, Fill, Stroke, Style} from 'ol/style'; +import {Circle as CircleStyle, Fill, Icon, Stroke, Style} from 'ol/style'; import proj4 from 'proj4'; -import {useCallback, useEffect, useState} from 'react'; - +import {useCallback, useEffect, useRef, useState} from 'react'; +import {transform} from 'ol/proj'; // define some EPSG codes - these are for two sample images // TODO: we need to have a better way to include a useful set or allow // them to be defined by a project @@ -46,6 +46,13 @@ register(proj4); export type MapAction = 'save' | 'close'; +// // To simulate movement for PR: open browser console and set `window.__USE_FAKE_GPS__ = true` +declare global { + interface Window { + __USE_FAKE_GPS__?: boolean; + } +} + interface MapProps extends ButtonProps { label: string; features: any; @@ -78,349 +85,369 @@ import {useNotification} from '../../../context/popup'; import {MapComponent} from '../../components/map/map-component'; import {theme} from '../../themes'; import {Extent} from 'ol/extent'; +import Feature from 'ol/Feature'; +import {Point} from 'ol/geom'; +import {RegularShape} from 'ol/style'; function MapWrapper(props: MapProps) { const [mapOpen, setMapOpen] = useState(false); const [map, setMap] = useState(); - const [featuresLayer, setFeaturesLayer] = useState>(); + const [featuresLayer, setFeaturesLayer] = useState(); const geoJson = new GeoJSON(); const [showConfirmSave, setShowConfirmSave] = useState(false); - const [featuresExtent, setFeaturesExtent] = useState(); + const [featuresExtent, setFeaturesExtent] = useState(); // notifications const notify = useNotification(); + // draw interaction with pin mark added and scaled const addDrawInteraction = useCallback( (theMap: Map, props: MapProps) => { - const source = new VectorSource(); + const vectorSource = new VectorSource(); + // @TODO: RG - Strech goal to show a popup on click of any point with lat-long info + const pinIcon = new Icon({ + src: 'https://cdn-icons-png.flaticon.com/512/684/684908.png', + anchor: [0.5, 1], + scale: 0.07, + }); + + const pinStyle = new Style({ + image: pinIcon, + }); const layer = new VectorLayer({ - source: source, - style: new Style({ - stroke: new Stroke({ - color: '#33ff33', - width: 4, - }), - image: new CircleStyle({ - radius: 7, - fill: new Fill({color: '#33ff33'}), - }), - }), + source: vectorSource, + style: pinStyle, }); + const draw = new Draw({ - source: source, + source: vectorSource, type: props.featureType || 'Point', }); - const modify = new Modify({ - source: source, + const modify = new Modify({source: vectorSource}); + + // Only allow one point at a time + draw.on('drawstart', () => { + vectorSource.clear(); }); - // add features to map if we're passed any in if (props.features && props.features.type) { const parsedFeatures = geoJson.readFeatures(props.features, { dataProjection: 'EPSG:4326', featureProjection: theMap.getView().getProjection(), }); - source.addFeatures(parsedFeatures); + vectorSource.addFeatures(parsedFeatures); - // set the view so that we can see the features - // but don't zoom too much - const extent = source.getExtent(); - // don't fit if the extent is infinite because it crashes - if (!extent.includes(Infinity)) { - setFeaturesExtent(extent); - } + const extent = vectorSource.getExtent(); + if (!extent.includes(Infinity)) setFeaturesExtent(extent); } theMap.addLayer(layer); theMap.addInteraction(draw); theMap.addInteraction(modify); setFeaturesLayer(layer); - - draw.on('drawstart', () => { - // clear any existing features if we start drawing again - // could allow up to a fixed number of features - // here by counting - source.clear(); - }); }, - [setFeaturesLayer] + [geoJson, setFeaturesExtent] ); - const handleClose = (action: 'save' | 'clear' | 'close') => { - if (featuresLayer) { - const source = featuresLayer.getSource(); + // save cleanr and close + const handleClose = (action: MapAction | 'clear') => { + if (!map) return; + + const source = featuresLayer?.getSource(); - if (source) { - const features = source.getFeatures(); + if (action === 'clear') { + source?.clear(); + return; + } + const features = source?.getFeatures() ?? []; + + if (featuresLayer) { + featuresLayer?.getSource()?.clear(); // just clear features, don’t remove layer + } - if (map) { - const geoJsonFeatures = geoJson.writeFeaturesObject(features, { - featureProjection: map.getView().getProjection(), - dataProjection: 'EPSG:4326', - rightHanded: true, - }); - if (action === 'clear') { - // if clearing - just remove locally don't callback so we don't save this change - source.clear(); - } else if (action === 'save') { - if (!features.length) { - setShowConfirmSave(true); // show confirmation dialog if no location is selected while saving. - return; - } - props.setFeatures(geoJsonFeatures, 'save'); - setMapOpen(false); - } else if (action === 'close') { - setMapOpen(false); - } - } + // action save + if (action === 'save') { + if (!features.length) { + setShowConfirmSave(true); + return; } + + const geoJsonFeatures = geoJson.writeFeaturesObject(features, { + featureProjection: map.getView().getProjection(), // EPSG:3857 + dataProjection: 'EPSG:4326', // convert back to EPSG:4326 + }); + + props.setFeatures(geoJsonFeatures, 'save'); + setMapOpen(false); + } else if (action === 'close') { + setMapOpen(false); } }; + // ppen map const handleClickOpen = () => { if (props.fallbackCenter) { notify.showWarning( - 'Using default map location - unable to determine current location and no center location configured.' + 'Using default map location - no current GPS or center.' ); } - // We always provide a center, so it's always safe to open the map setMapOpen(true); + setTimeout(() => { + if (map) { + map.getLayers().forEach(layer => { + if (layer.getZIndex?.() === 999) map.removeLayer(layer); // remove old layer + }); + map.updateSize(); // resize in case of fullscreen bug + map.getView().setZoom(props.zoom || 17); + } + }, 300); // delay to ensure DOM is ready }; + // always re-apply draw interaction if maps open. useEffect(() => { - if (map) { - addDrawInteraction(map, props); - } - }, [map]); + if (mapOpen && map) addDrawInteraction(map, props); + }, [mapOpen, map]); return ( -
- {!props.isLocationSelected ? ( - - ) : ( - - - - +
+ {!props.isLocationSelected ? ( + + ) : ( + + + - - Close - - + + + + )} - setMapOpen(false)}> + + - + setMapOpen(false)} + aria-label="close" + sx={{ + backgroundColor: theme.palette.primary.dark, + color: theme.palette.background.default, + fontSize: '16px', + gap: '4px', + fontWeight: 'bold', + borderRadius: '6px', + padding: '6px 12px', + transition: + 'background-color 0.3s ease-in-out, transform 0.2s ease-in-out', + '&:hover': { + backgroundColor: theme.palette.text.primary, + transform: 'scale(1.05)', + }, + }} + > + + Close + + - - - - + - {/*
*/} - - - - + + + + - setShowConfirmSave(false)}> - - No location selected - Are you sure you want to save an empty location selection? - - - - - - -
+ {/*
*/} + + + + + + setShowConfirmSave(false)} + > + + No location selected + Are you sure you want to save an empty location selection? + + + + + + +
+ + ); } // added forward rendering.. diff --git a/app/vite.config.ts b/app/vite.config.ts index c0cb3dab8..6c1c8ba99 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -32,9 +32,15 @@ export default defineConfig({ build: { outDir: 'build', }, + // server: { + // port: 3000, + // host: true, + // }, server: { + host: '0.0.0.0', port: 3000, - host: true, + strictPort: true, + allowedHosts: true, // to be removed later @TODO RG }, preview: { port: 3000,