@mytec: iter2.4 ready for testing
This commit is contained in:
@@ -102,6 +102,8 @@ export default function App() {
|
||||
const setShowElevationInfo = useSettingsStore((s) => s.setShowElevationInfo);
|
||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
|
||||
const setElevationOpacity = useSettingsStore((s) => s.setElevationOpacity);
|
||||
|
||||
// History (undo/redo)
|
||||
const canUndo = useHistoryStore((s) => s.canUndo);
|
||||
@@ -1059,6 +1061,19 @@ export default function App() {
|
||||
/>
|
||||
Elevation Colors
|
||||
</label>
|
||||
{showElevationOverlay && (
|
||||
<div className="pl-6">
|
||||
<NumberInput
|
||||
label="Opacity"
|
||||
value={Math.round(elevationOpacity * 100)}
|
||||
onChange={(v) => setElevationOpacity(v / 100)}
|
||||
min={10}
|
||||
max={100}
|
||||
step={10}
|
||||
unit="%"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
176
frontend/src/components/map/ElevationLayer.tsx
Normal file
176
frontend/src/components/map/ElevationLayer.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { api } from '@/services/api.ts';
|
||||
|
||||
interface ElevationLayerProps {
|
||||
visible: boolean;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
// Terrain color gradient: low = green, mid = yellow/tan, high = brown/white
|
||||
const COLOR_STOPS = [
|
||||
{ elev: 0, r: 20, g: 100, b: 40 }, // dark green
|
||||
{ elev: 100, r: 50, g: 160, b: 60 }, // green
|
||||
{ elev: 200, r: 130, g: 200, b: 80 }, // yellow-green
|
||||
{ elev: 350, r: 210, g: 190, b: 100 }, // tan
|
||||
{ elev: 500, r: 180, g: 140, b: 80 }, // brown
|
||||
{ elev: 800, r: 160, g: 120, b: 90 }, // dark brown
|
||||
{ elev: 1200, r: 200, g: 190, b: 180 }, // light grey
|
||||
{ elev: 2000, r: 240, g: 240, b: 240 }, // near white
|
||||
];
|
||||
|
||||
function getColorForElevation(elev: number): [number, number, number] {
|
||||
if (elev <= COLOR_STOPS[0].elev) {
|
||||
return [COLOR_STOPS[0].r, COLOR_STOPS[0].g, COLOR_STOPS[0].b];
|
||||
}
|
||||
|
||||
for (let i = 1; i < COLOR_STOPS.length; i++) {
|
||||
if (elev <= COLOR_STOPS[i].elev) {
|
||||
const low = COLOR_STOPS[i - 1];
|
||||
const high = COLOR_STOPS[i];
|
||||
const t = (elev - low.elev) / (high.elev - low.elev);
|
||||
return [
|
||||
Math.round(low.r + t * (high.r - low.r)),
|
||||
Math.round(low.g + t * (high.g - low.g)),
|
||||
Math.round(low.b + t * (high.b - low.b)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
const last = COLOR_STOPS[COLOR_STOPS.length - 1];
|
||||
return [last.r, last.g, last.b];
|
||||
}
|
||||
|
||||
export default function ElevationLayer({ visible, opacity }: ElevationLayerProps) {
|
||||
const map = useMap();
|
||||
const overlayRef = useRef<L.ImageOverlay | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const lastBoundsRef = useRef<string>('');
|
||||
|
||||
const removeOverlay = useCallback(() => {
|
||||
if (overlayRef.current) {
|
||||
map.removeLayer(overlayRef.current);
|
||||
overlayRef.current = null;
|
||||
}
|
||||
}, [map]);
|
||||
|
||||
const fetchAndRender = useCallback(async () => {
|
||||
// Abort previous request
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
abortRef.current = new AbortController();
|
||||
|
||||
const bounds = map.getBounds();
|
||||
const minLat = bounds.getSouth();
|
||||
const maxLat = bounds.getNorth();
|
||||
const minLon = bounds.getWest();
|
||||
const maxLon = bounds.getEast();
|
||||
|
||||
// Skip if bbox is too large (zoomed out too far)
|
||||
if ((maxLat - minLat) > 2.0 || (maxLon - minLon) > 2.0) {
|
||||
removeOverlay();
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if bounds haven't changed significantly
|
||||
const boundsKey = `${minLat.toFixed(3)},${maxLat.toFixed(3)},${minLon.toFixed(3)},${maxLon.toFixed(3)}`;
|
||||
if (boundsKey === lastBoundsRef.current) return;
|
||||
lastBoundsRef.current = boundsKey;
|
||||
|
||||
// Choose resolution based on viewport size
|
||||
const zoom = map.getZoom();
|
||||
const resolution = zoom >= 13 ? 150 : zoom >= 10 ? 100 : 60;
|
||||
|
||||
try {
|
||||
const data = await api.getElevationGrid(minLat, maxLat, minLon, maxLon, resolution);
|
||||
|
||||
// Check if component was unmounted or request was superseded
|
||||
if (abortRef.current?.signal.aborted) return;
|
||||
|
||||
// Render to canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = data.cols;
|
||||
canvas.height = data.rows;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const imageData = ctx.createImageData(data.cols, data.rows);
|
||||
const pixels = imageData.data;
|
||||
|
||||
for (let row = 0; row < data.rows; row++) {
|
||||
for (let col = 0; col < data.cols; col++) {
|
||||
const elev = data.grid[row][col];
|
||||
const [r, g, b] = getColorForElevation(elev);
|
||||
const idx = (row * data.cols + col) * 4;
|
||||
pixels[idx] = r;
|
||||
pixels[idx + 1] = g;
|
||||
pixels[idx + 2] = b;
|
||||
pixels[idx + 3] = 255;
|
||||
}
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Remove old overlay
|
||||
removeOverlay();
|
||||
|
||||
// Add new overlay
|
||||
const leafletBounds = L.latLngBounds(
|
||||
[data.bbox.min_lat, data.bbox.min_lon],
|
||||
[data.bbox.max_lat, data.bbox.max_lon],
|
||||
);
|
||||
overlayRef.current = L.imageOverlay(canvas.toDataURL(), leafletBounds, {
|
||||
opacity,
|
||||
interactive: false,
|
||||
zIndex: 97,
|
||||
});
|
||||
overlayRef.current.addTo(map);
|
||||
} catch (_e) {
|
||||
// Silently ignore fetch errors (network issues, aborts, etc.)
|
||||
}
|
||||
}, [map, opacity, removeOverlay]);
|
||||
|
||||
// Update opacity on existing overlay
|
||||
useEffect(() => {
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.setOpacity(opacity);
|
||||
}
|
||||
}, [opacity]);
|
||||
|
||||
// Main effect: toggle visibility and listen to map moves
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
removeOverlay();
|
||||
lastBoundsRef.current = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const onMoveEnd = () => {
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
debounceRef.current = setTimeout(() => {
|
||||
fetchAndRender();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
map.on('moveend', onMoveEnd);
|
||||
// Initial fetch
|
||||
fetchAndRender();
|
||||
|
||||
return () => {
|
||||
map.off('moveend', onMoveEnd);
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
}
|
||||
removeOverlay();
|
||||
};
|
||||
}, [map, visible, fetchAndRender, removeOverlay]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import MapExtras from './MapExtras.tsx';
|
||||
import CoordinateGrid from './CoordinateGrid.tsx';
|
||||
import MeasurementTool from './MeasurementTool.tsx';
|
||||
import ElevationDisplay from './ElevationDisplay.tsx';
|
||||
import ElevationLayer from './ElevationLayer.tsx';
|
||||
|
||||
interface MapViewProps {
|
||||
onMapClick: (lat: number, lon: number) => void;
|
||||
@@ -60,6 +61,7 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
const showElevationInfo = useSettingsStore((s) => s.showElevationInfo);
|
||||
const showElevationOverlay = useSettingsStore((s) => s.showElevationOverlay);
|
||||
const setShowElevationOverlay = useSettingsStore((s) => s.setShowElevationOverlay);
|
||||
const elevationOpacity = useSettingsStore((s) => s.elevationOpacity);
|
||||
const addToast = useToastStore((s) => s.addToast);
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
|
||||
@@ -95,16 +97,8 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
||||
zIndex={100}
|
||||
/>
|
||||
)}
|
||||
{/* Elevation color overlay (OpenTopoMap — no API key required) */}
|
||||
{showElevationOverlay && (
|
||||
<TileLayer
|
||||
attribution='Map data: © <a href="https://openstreetmap.org">OpenStreetMap</a>, SRTM | Style: © <a href="https://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)'
|
||||
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||||
opacity={0.5}
|
||||
maxZoom={17}
|
||||
zIndex={97}
|
||||
/>
|
||||
)}
|
||||
{/* Elevation color overlay from SRTM terrain data */}
|
||||
<ElevationLayer visible={showElevationOverlay} opacity={elevationOpacity} />
|
||||
<MapClickHandler onMapClick={onMapClick} />
|
||||
<MapExtras />
|
||||
{showElevationInfo && <ElevationDisplay />}
|
||||
|
||||
@@ -97,6 +97,22 @@ export interface Preset {
|
||||
estimated_speed: string;
|
||||
}
|
||||
|
||||
// === Elevation grid types ===
|
||||
|
||||
export interface ElevationGridResponse {
|
||||
grid: number[][];
|
||||
rows: number;
|
||||
cols: number;
|
||||
min_elevation: number;
|
||||
max_elevation: number;
|
||||
bbox: {
|
||||
min_lat: number;
|
||||
max_lat: number;
|
||||
min_lon: number;
|
||||
max_lon: number;
|
||||
};
|
||||
}
|
||||
|
||||
// === API Client ===
|
||||
|
||||
class ApiService {
|
||||
@@ -148,6 +164,27 @@ class ApiService {
|
||||
return data.elevation;
|
||||
}
|
||||
|
||||
async getElevationGrid(
|
||||
minLat: number,
|
||||
maxLat: number,
|
||||
minLon: number,
|
||||
maxLon: number,
|
||||
resolution: number = 100,
|
||||
): Promise<ElevationGridResponse> {
|
||||
const params = new URLSearchParams({
|
||||
min_lat: minLat.toString(),
|
||||
max_lat: maxLat.toString(),
|
||||
min_lon: minLon.toString(),
|
||||
max_lon: maxLon.toString(),
|
||||
resolution: resolution.toString(),
|
||||
});
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/terrain/elevation-grid?${params}`
|
||||
);
|
||||
if (!response.ok) throw new Error('Failed to fetch elevation grid');
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// === Region / Caching API ===
|
||||
|
||||
async getRegions(): Promise<RegionInfo[]> {
|
||||
|
||||
@@ -11,6 +11,7 @@ interface SettingsState {
|
||||
measurementMode: boolean;
|
||||
showElevationInfo: boolean;
|
||||
showElevationOverlay: boolean;
|
||||
elevationOpacity: number;
|
||||
setTheme: (theme: Theme) => void;
|
||||
setShowTerrain: (show: boolean) => void;
|
||||
setTerrainOpacity: (opacity: number) => void;
|
||||
@@ -18,6 +19,7 @@ interface SettingsState {
|
||||
setMeasurementMode: (mode: boolean) => void;
|
||||
setShowElevationInfo: (show: boolean) => void;
|
||||
setShowElevationOverlay: (show: boolean) => void;
|
||||
setElevationOpacity: (opacity: number) => void;
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
@@ -41,6 +43,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
measurementMode: false,
|
||||
showElevationInfo: false,
|
||||
showElevationOverlay: false,
|
||||
elevationOpacity: 0.5,
|
||||
setTheme: (theme: Theme) => {
|
||||
set({ theme });
|
||||
applyTheme(theme);
|
||||
@@ -51,6 +54,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setMeasurementMode: (mode: boolean) => set({ measurementMode: mode }),
|
||||
setShowElevationInfo: (show: boolean) => set({ showElevationInfo: show }),
|
||||
setShowElevationOverlay: (show: boolean) => set({ showElevationOverlay: show }),
|
||||
setElevationOpacity: (opacity: number) => set({ elevationOpacity: opacity }),
|
||||
}),
|
||||
{
|
||||
name: 'rfcp-settings',
|
||||
|
||||
Reference in New Issue
Block a user