@mytec: iter4 ready for test
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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='© <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: © OpenStreetMap, SRTM | Style: © <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} />
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user