@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
|
// Link budget: RSRP = P_tx + G_tx - FSPL
|
||||||
var rsrp = site.power + site.gain - 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) {
|
if (site.antennaType === 'sector' && site.azimuth !== undefined) {
|
||||||
var bearing = calculateBearing(site.lat, site.lon, point.lat, point.lon);
|
var bearing = calculateBearing(site.lat, site.lon, point.lat, point.lon);
|
||||||
var relativeAngle = Math.abs(bearing - site.azimuth);
|
var relativeAngle = Math.abs(bearing - site.azimuth);
|
||||||
var normalizedAngle =
|
var normalizedAngle =
|
||||||
relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
|
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(
|
var patternLoss = calculateSectorPatternLoss(
|
||||||
normalizedAngle,
|
normalizedAngle,
|
||||||
site.beamwidth || 65
|
beamwidth
|
||||||
);
|
);
|
||||||
rsrp -= patternLoss;
|
rsrp -= patternLoss;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import type { Site } from '@/types/index.ts';
|
import type { Site } from '@/types/index.ts';
|
||||||
import { useSitesStore } from '@/store/sites.ts';
|
import { useSitesStore } from '@/store/sites.ts';
|
||||||
import { useCoverageStore } from '@/store/coverage.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 { RFCalculator } from '@/rf/calculator.ts';
|
||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
||||||
@@ -33,6 +33,10 @@ export default function App() {
|
|||||||
|
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
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 [showForm, setShowForm] = useState(false);
|
||||||
const [editSite, setEditSite] = useState<Site | null>(null);
|
const [editSite, setEditSite] = useState<Site | null>(null);
|
||||||
const [pendingLocation, setPendingLocation] = useState<{
|
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"
|
className="w-full h-1.5 bg-gray-200 dark:bg-dark-border rounded-lg appearance-none cursor-pointer accent-blue-600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -81,6 +81,25 @@ export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps
|
|||||||
|
|
||||||
const { radius, blur, maxIntensity } = getHeatmapParams(mapZoom);
|
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, {
|
const heatLayer = L.heatLayer(heatData, {
|
||||||
radius,
|
radius,
|
||||||
blur,
|
blur,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export default function MapView({ onMapClick, onEditSite, children }: MapViewPro
|
|||||||
const sites = useSitesStore((s) => s.sites);
|
const sites = useSitesStore((s) => s.sites);
|
||||||
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
const isPlacingMode = useSitesStore((s) => s.isPlacingMode);
|
||||||
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
const showTerrain = useSettingsStore((s) => s.showTerrain);
|
||||||
|
const terrainOpacity = useSettingsStore((s) => s.terrainOpacity);
|
||||||
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
|
const setShowTerrain = useSettingsStore((s) => s.setShowTerrain);
|
||||||
const mapRef = useRef<LeafletMap | null>(null);
|
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>'
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
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 && (
|
{showTerrain && (
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='Map data: © OpenStreetMap, SRTM | Style: © <a href="https://opentopomap.org">OpenTopoMap</a>'
|
attribution='Map data: © OpenStreetMap, SRTM | Style: © <a href="https://opentopomap.org">OpenTopoMap</a>'
|
||||||
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
|
||||||
opacity={0.6}
|
opacity={terrainOpacity}
|
||||||
|
zIndex={100}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<MapClickHandler onMapClick={onMapClick} />
|
<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 L from 'leaflet';
|
||||||
import type { Site } from '@/types/index.ts';
|
import type { Site } from '@/types/index.ts';
|
||||||
import { useSitesStore } from '@/store/sites.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 }) {
|
function FlyToSelected({ site, isSelected }: { site: Site; isSelected: boolean }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -83,6 +111,19 @@ export default function SiteMarker({ site, onEdit }: SiteMarkerProps) {
|
|||||||
</div>
|
</div>
|
||||||
</Popup>
|
</Popup>
|
||||||
</Marker>
|
</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 {
|
interface SettingsState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
showTerrain: boolean;
|
showTerrain: boolean;
|
||||||
|
terrainOpacity: number;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setShowTerrain: (show: boolean) => void;
|
setShowTerrain: (show: boolean) => void;
|
||||||
|
setTerrainOpacity: (opacity: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyTheme(theme: Theme) {
|
function applyTheme(theme: Theme) {
|
||||||
@@ -26,11 +28,13 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
theme: 'system' as Theme,
|
theme: 'system' as Theme,
|
||||||
showTerrain: false,
|
showTerrain: false,
|
||||||
|
terrainOpacity: 0.5,
|
||||||
setTheme: (theme: Theme) => {
|
setTheme: (theme: Theme) => {
|
||||||
set({ theme });
|
set({ theme });
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
},
|
},
|
||||||
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
|
setShowTerrain: (show: boolean) => set({ showTerrain: show }),
|
||||||
|
setTerrainOpacity: (opacity: number) => set({ terrainOpacity: opacity }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'rfcp-settings',
|
name: 'rfcp-settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user