@mytec: iter2.4 ready for testing

This commit is contained in:
2026-02-01 10:48:23 +02:00
parent 7893c57bc9
commit 5488633e43
19 changed files with 1448 additions and 69 deletions

View File

@@ -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>

View 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;
}

View File

@@ -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: &copy; <a href="https://openstreetmap.org">OpenStreetMap</a>, SRTM | Style: &copy; <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 />}

View File

@@ -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[]> {

View File

@@ -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',