@mytec: 2nd iteration implemented for tests

This commit is contained in:
2026-01-30 08:39:49 +02:00
parent e59eb59525
commit d7f1204e35
6 changed files with 342 additions and 50 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet.heat';
@@ -28,9 +28,39 @@ function rsrpToIntensity(rsrp: number): number {
return Math.max(0, Math.min(1, (rsrp - min) / (max - min)));
}
/**
* Calculate adaptive heatmap radius and blur based on zoom level.
* Lower zoom (zoomed out) = larger radius for smoother appearance.
* Higher zoom (zoomed in) = smaller radius to avoid blocky squares.
*
* Zoom 6 (country): radius=35, blur=18
* Zoom 10 (region): radius=25, blur=13
* Zoom 14 (city): radius=15, blur=8
* Zoom 18 (street): radius=8, blur=6
*/
function getHeatmapParams(zoom: number) {
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
return { radius: Math.round(radius), blur: Math.round(blur) };
}
export default function Heatmap({ points, visible }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
// Track zoom changes
useEffect(() => {
const handleZoomEnd = () => {
setMapZoom(map.getZoom());
};
map.on('zoomend', handleZoomEnd);
return () => {
map.off('zoomend', handleZoomEnd);
};
}, [map]);
// Recreate heatmap layer when points, visibility, or zoom changes
useEffect(() => {
if (!visible || points.length === 0) return;
@@ -40,9 +70,11 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
rsrpToIntensity(p.rsrp),
]);
const { radius, blur } = getHeatmapParams(mapZoom);
const heatLayer = L.heatLayer(heatData, {
radius: 15,
blur: 20,
radius,
blur,
maxZoom: 17,
max: 1.0,
minOpacity: 0.3,
@@ -61,7 +93,7 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
return () => {
map.removeLayer(heatLayer);
};
}, [map, points, visible]);
}, [map, points, visible, mapZoom]);
return null;
}

View File

@@ -4,6 +4,7 @@ import 'leaflet/dist/leaflet.css';
import type { Map as LeafletMap } from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useSettingsStore } from '@/store/settings.ts';
import SiteMarker from './SiteMarker.tsx';
interface MapViewProps {
@@ -42,6 +43,8 @@ function MapRefSetter({ mapRef }: { mapRef: React.MutableRefObject<LeafletMap |
export default function MapView({ onMapClick, onEditSite, children }: MapViewProps) {
const sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const showTerrain = useSettingsStore((s) => s.showTerrain);
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
const mapRef = useRef<LeafletMap | null>(null);
const handleFitToSites = useCallback(() => {
@@ -62,10 +65,19 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
className={`w-full h-full ${isPlacingMode ? 'cursor-crosshair' : ''}`}
>
<MapRefSetter mapRef={mapRef} />
{/* Base OSM layer */}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Terrain overlay (OpenTopoMap, semi-transparent when enabled) */}
{showTerrain && (
<TileLayer
attribution='Map data: &copy; OpenStreetMap, SRTM | Style: &copy; <a href="https://opentopomap.org">OpenTopoMap</a>'
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
opacity={0.6}
/>
)}
<MapClickHandler onMapClick={onMapClick} />
{sites
.filter((s) => s.visible)
@@ -97,6 +109,16 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
>
Reset
</button>
<button
onClick={() => setShowTerrain(!showTerrain)}
className={`bg-white dark:bg-dark-surface shadow-lg rounded px-3 py-2 text-sm
hover:bg-gray-50 dark:hover:bg-dark-border transition-colors
text-gray-700 dark:text-dark-text min-h-[36px]
${showTerrain ? 'ring-2 ring-blue-500' : ''}`}
title={showTerrain ? 'Hide terrain' : 'Show terrain elevation'}
>
Topo
</button>
</div>
</>
);

View File

@@ -0,0 +1,96 @@
import { useState } from 'react';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
export default function BatchEdit() {
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
const clearSelection = useSitesStore((s) => s.clearSelection);
const addToast = useToastStore((s) => s.addToast);
const [customHeight, setCustomHeight] = useState('');
if (selectedSiteIds.length === 0) return null;
const handleAdjustHeight = async (delta: number) => {
await batchUpdateHeight(delta);
addToast(
`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
'success'
);
};
const handleSetHeight = async () => {
const height = parseInt(customHeight, 10);
if (isNaN(height) || height < 1 || height > 100) {
addToast('Height must be between 1-100m', 'error');
return;
}
await batchSetHeight(height);
addToast(`Set ${selectedSiteIds.length} site(s) to ${height}m`, 'success');
setCustomHeight('');
};
return (
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-200">
Batch Edit ({selectedSiteIds.length} selected)
</h3>
<Button onClick={clearSelection} size="sm" variant="ghost">
Clear
</Button>
</div>
{/* Quick height adjustments */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Adjust Height:
</label>
<div className="flex gap-1.5 flex-wrap">
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(10)}>
+10m
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(5)}>
+5m
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-5)}>
-5m
</Button>
<Button size="sm" variant="secondary" onClick={() => handleAdjustHeight(-10)}>
-10m
</Button>
</div>
</div>
{/* Set exact height */}
<div>
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-1.5 block">
Set Height:
</label>
<div className="flex gap-2">
<input
type="number"
min={1}
max={100}
value={customHeight}
onChange={(e) => setCustomHeight(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSetHeight()}
placeholder="meters"
className="flex-1 px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<Button
size="sm"
onClick={handleSetHeight}
disabled={!customHeight}
>
Apply
</Button>
</div>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
import BatchEdit from './BatchEdit.tsx';
interface SiteListProps {
onEditSite: (site: Site) => void;
@@ -15,6 +16,10 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
const selectedSiteId = useSitesStore((s) => s.selectedSiteId);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const togglePlacingMode = useSitesStore((s) => s.togglePlacingMode);
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
const toggleSiteSelection = useSitesStore((s) => s.toggleSiteSelection);
const selectAllSites = useSitesStore((s) => s.selectAllSites);
const clearSelection = useSitesStore((s) => s.clearSelection);
const addToast = useToastStore((s) => s.addToast);
const handleDelete = async (id: string, name: string) => {
@@ -22,8 +27,11 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
addToast(`"${name}" deleted`, 'info');
};
const allSelected = sites.length > 0 && selectedSiteIds.length === sites.length;
return (
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 dark:border-dark-border flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Sites ({sites.length})
@@ -42,20 +50,61 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
</div>
</div>
{/* Select All row (only when sites exist) */}
{sites.length > 0 && (
<div className="px-4 py-1.5 border-b border-gray-100 dark:border-dark-border flex items-center justify-between">
<label className="flex items-center gap-2 cursor-pointer text-xs text-gray-500 dark:text-dark-muted">
<input
type="checkbox"
checked={allSelected}
onChange={() => (allSelected ? clearSelection() : selectAllSites())}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
/>
{allSelected ? 'Deselect All' : 'Select All'}
</label>
{selectedSiteIds.length > 0 && (
<span className="text-xs text-blue-600 dark:text-blue-400">
{selectedSiteIds.length} selected
</span>
)}
</div>
)}
{/* Batch edit panel (appears when sites are selected) */}
{selectedSiteIds.length > 0 && (
<div className="px-3 pt-3">
<BatchEdit />
</div>
)}
{/* Sites list */}
{sites.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400 dark:text-dark-muted">
No sites yet. Click on the map or use "+ Manual" to add one.
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
{sites.map((site) => (
{sites.map((site) => {
const isBatchSelected = selectedSiteIds.includes(site.id);
return (
<div
key={site.id}
className={`px-4 py-2.5 flex items-center gap-3 cursor-pointer
className={`px-4 py-2.5 flex items-center gap-2.5 cursor-pointer
hover:bg-gray-50 dark:hover:bg-dark-border/50 transition-colors
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}`}
${selectedSiteId === site.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''}
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}`}
onClick={() => selectSite(site.id)}
>
{/* Batch checkbox */}
<input
type="checkbox"
checked={isBatchSelected}
onChange={() => toggleSiteSelection(site.id)}
onClick={(e) => e.stopPropagation()}
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600 flex-shrink-0"
/>
{/* Color indicator */}
<div
className="w-4 h-4 rounded-full flex-shrink-0 border-2 border-white dark:border-dark-border shadow"
@@ -98,7 +147,8 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
</button>
</div>
</div>
))}
);
})}
</div>
)}
</div>

View File

@@ -5,7 +5,9 @@ type Theme = 'light' | 'dark' | 'system';
interface SettingsState {
theme: Theme;
showTerrain: boolean;
setTheme: (theme: Theme) => void;
setShowTerrain: (show: boolean) => void;
}
function applyTheme(theme: Theme) {
@@ -23,10 +25,12 @@ export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system' as Theme,
showTerrain: false,
setTheme: (theme: Theme) => {
set({ theme });
applyTheme(theme);
},
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
}),
{
name: 'rfcp-settings',

View File

@@ -13,6 +13,7 @@ interface SitesState {
selectedSiteId: string | null;
editingSiteId: string | null;
isPlacingMode: boolean;
selectedSiteIds: string[];
loadSites: () => Promise<void>;
addSite: (data: SiteFormData) => Promise<Site>;
@@ -22,6 +23,13 @@ interface SitesState {
setEditingSite: (id: string | null) => void;
togglePlacingMode: () => void;
setPlacingMode: (val: boolean) => void;
// Batch operations
toggleSiteSelection: (siteId: string) => void;
selectAllSites: () => void;
clearSelection: () => void;
batchUpdateHeight: (adjustment: number) => Promise<void>;
batchSetHeight: (height: number) => Promise<void>;
}
export const useSitesStore = create<SitesState>((set, get) => ({
@@ -29,6 +37,7 @@ export const useSitesStore = create<SitesState>((set, get) => ({
selectedSiteId: null,
editingSiteId: null,
isPlacingMode: false,
selectedSiteIds: [],
loadSites: async () => {
const dbSites = await db.sites.toArray();
@@ -96,4 +105,83 @@ export const useSitesStore = create<SitesState>((set, get) => ({
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
// Batch operations
toggleSiteSelection: (siteId: string) => {
set((state) => {
const isSelected = state.selectedSiteIds.includes(siteId);
return {
selectedSiteIds: isSelected
? state.selectedSiteIds.filter((id) => id !== siteId)
: [...state.selectedSiteIds, siteId],
};
});
},
selectAllSites: () => {
set((state) => ({
selectedSiteIds: state.sites.map((s) => s.id),
}));
},
clearSelection: () => {
set({ selectedSiteIds: [] });
},
batchUpdateHeight: async (adjustment: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
return {
...site,
height: Math.max(1, Math.min(100, site.height + adjustment)),
updatedAt: now,
};
});
// Persist to IndexedDB
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
},
batchSetHeight: async (height: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const clampedHeight = Math.max(1, Math.min(100, height));
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
return {
...site,
height: clampedHeight,
updatedAt: now,
};
});
// Persist to IndexedDB
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
},
}));