Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/components/Navbar/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ const adminLinks = [
{ link: '/plugin_updates', label: t('Plugin Updates'), icon: IconPuzzle },
{ link: '/device_profiles', label: t('Device Profiles'), icon: IconDeviceMobile },
{ link: '/server_plugin_manager', label: t('Server Plugin Manager'), icon: IconPlugConnected },
{ link: '/link_account', 'label': t('Link TAK.gov Account'), icon: IconLink}
{ link: '/link_account', 'label': t('Link TAK.gov Account'), icon: IconLink},
{ link: '/settings', label: t('Settings'), icon: IconSettings },
];

interface ATAKQrCode {
Expand Down
63 changes: 53 additions & 10 deletions src/pages/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,38 @@ export default function Map() {
const [positionRows, setPositionRows] = useState<ReactElement[]>([]);
const computedColorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });

const [mapCenter, setMapCenter] = useState<[number, number]>([10, 0]);
const [mapZoom, setMapZoom] = useState<number>(3);
const [defaultLayer, setDefaultLayer] = useState<string>('OSM');
const [mapReady, setMapReady] = useState(false);

useEffect(() => {
const savedLat = localStorage.getItem('ots_map_lat');
const savedLon = localStorage.getItem('ots_map_lon');
const savedZoom = localStorage.getItem('ots_map_zoom');
const savedLayer = localStorage.getItem('ots_map_layer');

if (savedLat && savedLon && savedZoom) {
setMapCenter([parseFloat(savedLat), parseFloat(savedLon)]);
setMapZoom(parseInt(savedZoom));
if (savedLayer) setDefaultLayer(savedLayer);
setMapReady(true);
} else {
axios.get(apiRoutes.adminSettings)
.then(r => {
if (r.status === 200) {
setMapCenter([r.data.OTS_MAP_DEFAULT_LAT, r.data.OTS_MAP_DEFAULT_LON]);
setMapZoom(r.data.OTS_MAP_DEFAULT_ZOOM);
setDefaultLayer(r.data.OTS_MAP_DEFAULT_LAYER);
}
setMapReady(true);
})
.catch(() => {
setMapReady(true);
});
}
}, []);

const eudsLayer = new L.LayerGroup();
const rbLinesLayer = new L.LayerGroup();
const markersLayer = new L.LayerGroup();
Expand Down Expand Up @@ -435,6 +467,17 @@ export default function Map() {
socket.on('eud', onEud);
socket.on('casevac', onCaseEvac);

map.on('moveend', () => {
const center = map.getCenter();
localStorage.setItem('ots_map_lat', String(center.lat));
localStorage.setItem('ots_map_lon', String(center.lng));
localStorage.setItem('ots_map_zoom', String(map.getZoom()));
});

map.on('baselayerchange', (e: any) => {
localStorage.setItem('ots_map_layer', e.name);
});

return () => {
socket.off('point', onPointEvent);
socket.off('rb_line', onRBLine);
Expand Down Expand Up @@ -475,16 +518,16 @@ export default function Map() {
</Table>
</Drawer>
<Paper shadow="xl" radius="md" p="md" withBorder>
<MapContainer
center={[10, 0]}
zoom={3}
{mapReady && <MapContainer
center={mapCenter}
zoom={mapZoom}
scrollWheelZoom
style={{ height: 'calc(100vh - 10rem)', width: '100%', zIndex: 90 }}
>
<MapContext />
<ScaleControl />
<LayersControl>
<LayersControl.BaseLayer name="OSM" checked>
<LayersControl.BaseLayer name="OSM" checked={defaultLayer === 'OSM'}>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
Expand All @@ -493,19 +536,19 @@ export default function Map() {
maxZoom={20}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Google Streets">
<LayersControl.BaseLayer name="Google Streets" checked={defaultLayer === 'Google Streets'}>
<TileLayer url="http://mt0.google.com/vt/lyrs=m&x={x}&y={y}&z={z}" zIndex={0} minZoom={0} maxZoom={20} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Google Hybrid">
<LayersControl.BaseLayer name="Google Hybrid" checked={defaultLayer === 'Google Hybrid'}>
<TileLayer url="http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga" zIndex={0} minZoom={0} maxZoom={20} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Google Terrain">
<LayersControl.BaseLayer name="Google Terrain" checked={defaultLayer === 'Google Terrain'}>
<TileLayer url="http://mt1.google.com/vt/lyrs=p&amp;x={x}&amp;y={y}&amp;z={z}" zIndex={0} minZoom={0} maxZoom={20} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="ESRI World Imagery (Clarity) Beta">
<LayersControl.BaseLayer name="ESRI World Imagery (Clarity) Beta" checked={defaultLayer === 'ESRI World Imagery (Clarity) Beta'}>
<TileLayer url="http://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}" minZoom={0} maxZoom={20} />
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="ESRI World Topo">
<LayersControl.BaseLayer name="ESRI World Topo" checked={defaultLayer === 'ESRI World Topo'}>
<TileLayer url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}" minZoom={0} maxZoom={20} />
</LayersControl.BaseLayer>
</LayersControl>
Expand Down Expand Up @@ -534,7 +577,7 @@ export default function Map() {
<TileLayer url="http://mt1.google.com/vt/lyrs=t&amp;x={x}&amp;y={y}&amp;z={z}" pane="overlayPane" />
</LayersControl.Overlay>
</LayersControl>
</MapContainer>
</MapContainer>}
</Paper>
</>
);
Expand Down
220 changes: 220 additions & 0 deletions src/pages/Settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Button,
Grid,
NumberInput,
Paper,
Select,
Title,
} from '@mantine/core';
import { IconCheck, IconX } from '@tabler/icons-react';
import { notifications } from '@mantine/notifications';
import { MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import axios from '../axios_config';
import { apiRoutes } from '../apiRoutes';
import { t } from 'i18next';

const LAYER_OPTIONS = [
'OSM',
'Google Streets',
'Google Hybrid',
'Google Terrain',
'ESRI World Imagery (Clarity) Beta',
'ESRI World Topo',
];

const TILE_URLS: Record<string, string> = {
'OSM': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'Google Streets': 'http://mt0.google.com/vt/lyrs=m&x={x}&y={y}&z={z}',
'Google Hybrid': 'http://mt0.google.com/vt/lyrs=y&hl=en&x={x}&y={y}&z={z}&s=Ga',
'Google Terrain': 'http://mt1.google.com/vt/lyrs=p&x={x}&y={y}&z={z}',
'ESRI World Imagery (Clarity) Beta': 'http://clarity.maptiles.arcgis.com/arcgis/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
'ESRI World Topo': 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}',
};

function MapEvents({ onClickMap, onZoomEnd }: { onClickMap: (lat: number, lon: number) => void, onZoomEnd: (zoom: number, lat: number, lon: number) => void }) {
useMapEvents({
click(e) {
onClickMap(e.latlng.lat, e.latlng.lng);
},
zoomend(e) {
const center = e.target.getCenter();
onZoomEnd(e.target.getZoom(), center.lat, center.lng);
},
});
return null;
}

function MapSync({ lat, lon, zoom }: { lat: number, lon: number, zoom: number }) {
const map = useMap();
const isFirstRender = useRef(true);
const prevLat = useRef(lat);
const prevLon = useRef(lon);
const prevZoom = useRef(zoom);

useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
const latLonChanged = prevLat.current !== lat || prevLon.current !== lon;
const zoomChanged = prevZoom.current !== zoom;
prevLat.current = lat;
prevLon.current = lon;
prevZoom.current = zoom;

if (latLonChanged) {
map.setView([lat, lon], zoom);
} else if (zoomChanged) {
map.setZoom(zoom);
}
}, [lat, lon, zoom]);

return null;
}

export default function Settings() {
const [lat, setLat] = useState<number>(10);
const [lon, setLon] = useState<number>(0);
const [zoom, setZoom] = useState<number>(3);
const [layer, setLayer] = useState<string>('OSM');
const [loading, setLoading] = useState(true);

useEffect(() => {
axios.get(apiRoutes.adminSettings)
.then(r => {
if (r.status === 200) {
setLat(r.data.OTS_MAP_DEFAULT_LAT);
setLon(r.data.OTS_MAP_DEFAULT_LON);
setZoom(r.data.OTS_MAP_DEFAULT_ZOOM);
setLayer(r.data.OTS_MAP_DEFAULT_LAYER);
}
setLoading(false);
})
.catch(err => {
setLoading(false);
if (err.response && err.response.status === 404) {
notifications.show({
title: t('Settings unavailable'),
message: t('This feature requires a newer version of OpenTAKServer'),
icon: <IconX />,
color: 'orange',
});
} else {
notifications.show({
title: t('Failed to load settings'),
message: err.response?.data?.error || String(err),
icon: <IconX />,
color: 'red',
});
}
});
}, []);

function saveSettings() {
axios.put(apiRoutes.adminSettings, {
OTS_MAP_DEFAULT_LAT: lat,
OTS_MAP_DEFAULT_LON: lon,
OTS_MAP_DEFAULT_ZOOM: zoom,
OTS_MAP_DEFAULT_LAYER: layer,
}).then(r => {
if (r.status === 200) {
notifications.show({
message: t('Settings saved'),
icon: <IconCheck />,
color: 'green',
});
}
}).catch(err => {
notifications.show({
title: t('Failed to save settings'),
message: err.response?.data?.error || String(err),
icon: <IconX />,
color: 'red',
});
});
}

return (
<>
<Title order={3} mb="md">{t('Map Defaults')}</Title>
<Grid>
<Grid.Col span={{ base: 12, md: 4 }}>
<Paper shadow="xs" p="md" withBorder>
<NumberInput
label={t('Default Latitude')}
value={lat}
onChange={(v) => setLat(Number(v))}
min={-90}
max={90}
decimalScale={6}
step={0.1}
mb="md"
disabled={loading}
/>
<NumberInput
label={t('Default Longitude')}
value={lon}
onChange={(v) => setLon(Number(v))}
min={-180}
max={180}
decimalScale={6}
step={0.1}
mb="md"
disabled={loading}
/>
<NumberInput
label={t('Default Zoom')}
value={zoom}
onChange={(v) => setZoom(Number(v))}
min={1}
max={20}
mb="md"
disabled={loading}
/>
<Select
label={t('Default Map Layer')}
value={layer}
onChange={(v) => setLayer(v || 'OSM')}
data={LAYER_OPTIONS}
mb="md"
disabled={loading}
allowDeselect={false}
/>
<Button onClick={saveSettings} disabled={loading}>{t('Save')}</Button>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 8 }}>
<Paper shadow="xs" p="md" withBorder>
{!loading && <MapContainer
center={[lat, lon]}
zoom={zoom}
scrollWheelZoom
style={{ height: '500px', width: '100%', zIndex: 1 }}
>
<TileLayer
url={TILE_URLS[layer] || TILE_URLS['OSM']}
key={layer}
minZoom={0}
maxZoom={20}
/>
<MapEvents
onClickMap={(newLat, newLon) => {
setLat(parseFloat(newLat.toFixed(6)));
setLon(parseFloat(newLon.toFixed(6)));
}}
onZoomEnd={(newZoom, newLat, newLon) => {
setZoom(newZoom);
setLat(parseFloat(newLat.toFixed(6)));
setLon(parseFloat(newLon.toFixed(6)));
}}
/>
<MapSync lat={lat} lon={lon} zoom={zoom} />
</MapContainer>}
</Paper>
</Grid.Col>
</Grid>
</>
);
}
2 changes: 2 additions & 0 deletions src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const Plugin = React.lazy(() => import('./pages/Plugin'));
const ServerPluginManager = React.lazy(() => import('./pages/ServerPluginManager.tsx'));
const LinkTAKGovAccount = React.lazy(() => import('./pages/LinkTakGov.tsx'));
const UserProfile = React.lazy(() => import('./pages/UserProfile.tsx'));
const Settings = React.lazy(() => import('./pages/Settings.tsx'));

const routes = [
{ path: '/', exact: true, name: 'Home', element: Dashboard },
Expand Down Expand Up @@ -52,6 +53,7 @@ const routes = [
{ path: '/link_account', name: 'Link TAK.gov Account', element: LinkTAKGovAccount },
{ path: '/profile/', name: 'User Profile', element: UserProfile },
{ path: '/profile/:username', name: 'User Profile', element: UserProfile },
{ path: '/settings', name: 'Settings', element: Settings },
];

export default routes;