@mytec: iter4 ready for test

This commit is contained in:
2026-01-30 11:55:21 +02:00
parent 201aeeabd6
commit 641832bc7b
6 changed files with 100 additions and 6 deletions

View File

@@ -56,16 +56,24 @@ function calculatePointRSRP(site, point) {
// Link budget: RSRP = P_tx + G_tx - FSPL
var rsrp = site.power + site.gain - fspl;
// Apply sector antenna pattern loss
// Apply sector antenna directivity: hard cutoff + gradual pattern loss
if (site.antennaType === 'sector' && site.azimuth !== undefined) {
var bearing = calculateBearing(site.lat, site.lon, point.lat, point.lon);
var relativeAngle = Math.abs(bearing - site.azimuth);
var normalizedAngle =
relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
var beamwidth = site.beamwidth || 65;
// Hard cutoff: no signal outside beamwidth
if (normalizedAngle > beamwidth / 2) {
return -Infinity;
}
// Gradual 3GPP pattern loss within beamwidth
var patternLoss = calculateSectorPatternLoss(
normalizedAngle,
site.beamwidth || 65
beamwidth
);
rsrp -= patternLoss;
}

View File

@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import '@/store/settings.ts'; // Side-effect: initializes theme on load
import { useSettingsStore } from '@/store/settings.ts';
import { RFCalculator } from '@/rf/calculator.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
@@ -33,6 +33,10 @@ export default function App() {
const addToast = useToastStore((s) => s.addToast);
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setTerrainOpacity = useSettingsStore((s) => s.setTerrainOpacity);
const [showForm, setShowForm] = useState(false);
const [editSite, setEditSite] = useState<Site | null>(null);
const [pendingLocation, setPendingLocation] = useState<{
@@ -372,6 +376,22 @@ export default function App() {
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
/>
</div>
<div>
<label className="text-xs text-gray-500 dark:text-dark-muted">
Terrain Opacity: {Math.round(terrainOpacity * 100)}%
{!showTerrain && ' (disabled)'}
</label>
<input
type="range"
min={0.1}
max={1.0}
step={0.1}
value={terrainOpacity}
onChange={(e) => setTerrainOpacity(Number(e.target.value))}
disabled={!showTerrain}
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600 disabled:opacity-40"
/>
</div>
</div>
</div>

View File

@@ -81,6 +81,25 @@ export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
// Debug: log RSRP stats and heatmap params
if (import.meta.env.DEV) {
const rsrpValues = points.map((p) => p.rsrp);
const intensityValues = heatData.map((d) => d[2]);
console.log('Heatmap Debug:', {
pointCount: points.length,
rsrpMin: Math.min(...rsrpValues).toFixed(1),
rsrpMax: Math.max(...rsrpValues).toFixed(1),
rsrpSample: rsrpValues.slice(0, 5).map((v) => v.toFixed(1)),
intensityMin: Math.min(...intensityValues).toFixed(3),
intensityMax: Math.max(...intensityValues).toFixed(3),
mapZoom,
radius,
blur,
maxIntensity: maxIntensity.toFixed(2),
opacity,
});
}
const heatLayer = L.heatLayer(heatData, {
radius,
blur,

View File

@@ -44,6 +44,7 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
const sites = useSitesStore((s) => s.sites);
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
const showTerrain = useSettingsStore((s) => s.showTerrain);
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
const mapRef = useRef<LeafletMap | null>(null);
@@ -70,12 +71,13 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
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) */}
{/* Terrain overlay (OpenTopoMap, above base map, below heatmap) */}
{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}
opacity={terrainOpacity}
zIndex={100}
/>
)}
<MapClickHandler onMapClick={onMapClick} />

View File

@@ -1,4 +1,4 @@
import { Marker, Popup, useMap } from 'react-leaflet';
import { Marker, Popup, Polygon, useMap } from 'react-leaflet';
import L from 'leaflet';
import type { Site } from '@/types/index.ts';
import { useSitesStore } from '@/store/sites.ts';
@@ -27,6 +27,34 @@ function createSiteIcon(color: string, isSelected: boolean): L.DivIcon {
});
}
/**
* Generate polygon points for a sector antenna wedge visualization.
* Creates an arc from the site center spanning the beamwidth around the azimuth.
*/
function generateSectorWedge(site: Site): [number, number][] {
const points: [number, number][] = [[site.lat, site.lon]];
const radius = 0.5; // km visual radius on map
const beamwidth = site.beamwidth || 65;
const azimuth = site.azimuth || 0;
const startAngle = azimuth - beamwidth / 2;
const endAngle = azimuth + beamwidth / 2;
// Generate arc points every 5 degrees
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const rad = (angle * Math.PI) / 180;
// North = 0°, East = 90° — use sin for lon offset, cos for lat offset
const latOffset = (radius / 111) * Math.cos(rad);
const lonOffset =
(radius / (111 * Math.cos((site.lat * Math.PI) / 180))) * Math.sin(rad);
points.push([site.lat + latOffset, site.lon + lonOffset]);
}
// Close the wedge back to origin
points.push([site.lat, site.lon]);
return points;
}
function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }) {
const map = useMap();
useEffect(() => {
@@ -83,6 +111,19 @@ export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
</div>
</Popup>
</Marker>
{site.antennaType === 'sector' && (
<Polygon
positions={generateSectorWedge(site)}
pathOptions={{
color: site.color,
weight: 2,
opacity: 0.6,
fillColor: site.color,
fillOpacity: 0.1,
dashArray: '5, 5',
}}
/>
)}
</>
);
}

View File

@@ -6,8 +6,10 @@ type Theme = 'light' | 'dark' | 'system';
interface SettingsState {
theme: Theme;
showTerrain: boolean;
terrainOpacity: number;
setTheme: (theme: Theme) => void;
setShowTerrain: (show: boolean) => void;
setTerrainOpacity: (opacity: number) => void;
}
function applyTheme(theme: Theme) {
@@ -26,11 +28,13 @@ export const useSettingsStore = create<SettingsState>()(
(set) => ({
theme: 'system' as Theme,
showTerrain: false,
terrainOpacity: 0.5,
setTheme: (theme: Theme) => {
set({ theme });
applyTheme(theme);
},
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
}),
{
name: 'rfcp-settings',