@mytec: iter5 ready for test
This commit is contained in:
@@ -42,6 +42,13 @@ self.onmessage = function (e) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate RSRP at a specific point (universal formula)
|
* Calculate RSRP at a specific point (universal formula)
|
||||||
|
*
|
||||||
|
* RSRP = P_tx + G_tx - FSPL - PatternLoss
|
||||||
|
*
|
||||||
|
* Includes:
|
||||||
|
* - Antenna gain (site.gain)
|
||||||
|
* - 3GPP sector pattern with back lobe (no hard cutoff)
|
||||||
|
* - Radio horizon limit based on antenna height
|
||||||
*/
|
*/
|
||||||
function calculatePointRSRP(site, point) {
|
function calculatePointRSRP(site, point) {
|
||||||
var distance = haversineDistance(site.lat, site.lon, point.lat, point.lon);
|
var distance = haversineDistance(site.lat, site.lon, point.lat, point.lon);
|
||||||
@@ -49,6 +56,14 @@ function calculatePointRSRP(site, point) {
|
|||||||
// Minimum distance to prevent -Infinity
|
// Minimum distance to prevent -Infinity
|
||||||
if (distance < 0.01) distance = 0.01;
|
if (distance < 0.01) distance = 0.01;
|
||||||
|
|
||||||
|
// Radio horizon check: d_horizon = 3.57 * sqrt(h_meters)
|
||||||
|
// Uses 4/3 Earth radius for standard atmospheric refraction
|
||||||
|
var siteHeight = site.height || 10; // default 10m if missing
|
||||||
|
var horizon = calculateRadioHorizon(siteHeight);
|
||||||
|
if (distance > horizon) {
|
||||||
|
return -Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
// Free space path loss (universal)
|
// Free space path loss (universal)
|
||||||
var fspl =
|
var fspl =
|
||||||
20 * Math.log10(distance) + 20 * Math.log10(site.frequency) + 32.45;
|
20 * Math.log10(distance) + 20 * Math.log10(site.frequency) + 32.45;
|
||||||
@@ -56,31 +71,63 @@ 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 directivity: hard cutoff + gradual pattern loss
|
// Apply 3GPP sector antenna pattern (main lobe + side lobes + back lobe)
|
||||||
|
// No hard cutoff — back lobe is attenuated by front-to-back ratio (~25 dB)
|
||||||
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 normalizedAngle =
|
|
||||||
relativeAngle > 180 ? 360 - relativeAngle : relativeAngle;
|
|
||||||
|
|
||||||
var beamwidth = site.beamwidth || 65;
|
var beamwidth = site.beamwidth || 65;
|
||||||
|
var frontBackRatio = 25; // dB, typical for sector panel antennas
|
||||||
|
|
||||||
// Hard cutoff: no signal outside beamwidth
|
var patternLoss = calculate3GPPPattern(
|
||||||
if (normalizedAngle > beamwidth / 2) {
|
site.azimuth,
|
||||||
return -Infinity;
|
bearing,
|
||||||
}
|
beamwidth,
|
||||||
|
frontBackRatio
|
||||||
// Gradual 3GPP pattern loss within beamwidth
|
|
||||||
var patternLoss = calculateSectorPatternLoss(
|
|
||||||
normalizedAngle,
|
|
||||||
beamwidth
|
|
||||||
);
|
);
|
||||||
rsrp -= patternLoss;
|
rsrp -= patternLoss; // patternLoss is positive dB
|
||||||
}
|
}
|
||||||
|
|
||||||
return rsrp;
|
return rsrp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Radio horizon distance in km.
|
||||||
|
* d = 3.57 * sqrt(h) where h is antenna height in meters.
|
||||||
|
* Accounts for 4/3 Earth radius (standard atmosphere refraction).
|
||||||
|
*/
|
||||||
|
function calculateRadioHorizon(heightMeters) {
|
||||||
|
return 3.57 * Math.sqrt(heightMeters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3GPP TR 36.814 Horizontal Antenna Pattern.
|
||||||
|
*
|
||||||
|
* A(θ) = min[ 12 * (θ / θ_3dB)², A_m ]
|
||||||
|
*
|
||||||
|
* Returns POSITIVE dB loss value (to be subtracted from RSRP).
|
||||||
|
*
|
||||||
|
* - θ = angular offset from boresight (0-180°)
|
||||||
|
* - θ_3dB = half-power beamwidth / 2
|
||||||
|
* - A_m = maximum attenuation = front-to-back ratio
|
||||||
|
*
|
||||||
|
* At 0° → 0 dB loss (boresight)
|
||||||
|
* At ±θ_3dB → 3 dB loss (half-power points)
|
||||||
|
* At ±90° → capped at A_m (~25 dB)
|
||||||
|
* At 180° → capped at A_m (~25 dB, back lobe)
|
||||||
|
*/
|
||||||
|
function calculate3GPPPattern(azimuth, bearing, beamwidth, frontBackRatio) {
|
||||||
|
// Normalize angle difference to -180…+180
|
||||||
|
var angleDiff = bearing - azimuth;
|
||||||
|
while (angleDiff > 180) angleDiff -= 360;
|
||||||
|
while (angleDiff < -180) angleDiff += 360;
|
||||||
|
|
||||||
|
var theta = Math.abs(angleDiff);
|
||||||
|
var theta3dB = beamwidth / 2;
|
||||||
|
var Am = frontBackRatio;
|
||||||
|
|
||||||
|
return Math.min(12 * Math.pow(theta / theta3dB, 2), Am);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Haversine distance in km
|
* Haversine distance in km
|
||||||
*/
|
*/
|
||||||
@@ -116,16 +163,3 @@ function calculateBearing(lat1, lon1, lat2, lon2) {
|
|||||||
var bearing = (Math.atan2(y, x) * 180) / Math.PI;
|
var bearing = (Math.atan2(y, x) * 180) / Math.PI;
|
||||||
return (bearing + 360) % 360;
|
return (bearing + 360) % 360;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sector antenna pattern loss (3GPP model)
|
|
||||||
*/
|
|
||||||
function calculateSectorPatternLoss(angleOffBoresight, beamwidth) {
|
|
||||||
var theta3dB = beamwidth / 2;
|
|
||||||
var sideLobeLevel = 20;
|
|
||||||
|
|
||||||
return Math.min(
|
|
||||||
12 * Math.pow(angleOffBoresight / theta3dB, 2),
|
|
||||||
sideLobeLevel
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -30,26 +30,24 @@ function rsrpToIntensity(rsrp: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate adaptive heatmap params based on zoom level.
|
* Calculate adaptive heatmap visual params based on zoom level.
|
||||||
*
|
*
|
||||||
* radius/blur: smaller at close zoom to avoid blocky squares
|
* Only radius and blur change with zoom (visual quality).
|
||||||
* maxIntensity: lower at close zoom so densely packed points
|
* maxIntensity is CONSTANT at 1.0 so the same RSRP always
|
||||||
* don't all saturate to a single solid color
|
* maps to the same color regardless of zoom level.
|
||||||
*
|
*
|
||||||
* Zoom 6 (country): radius=35, blur=18, max=0.90
|
* Zoom 6 (country): radius=35, blur=18
|
||||||
* Zoom 10 (region): radius=25, blur=13, max=0.70
|
* Zoom 10 (region): radius=25, blur=13
|
||||||
* Zoom 14 (city): radius=15, blur=9, max=0.50
|
* Zoom 14 (city): radius=15, blur=9
|
||||||
* Zoom 18 (street): radius=8, blur=6, max=0.30
|
* Zoom 18 (street): radius=8, blur=6
|
||||||
*/
|
*/
|
||||||
function getHeatmapParams(zoom: number) {
|
function getHeatmapParams(zoom: number) {
|
||||||
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
|
const radius = Math.max(8, Math.min(40, 50 - zoom * 2.5));
|
||||||
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
|
const blur = Math.max(6, Math.min(20, 30 - zoom * 1.5));
|
||||||
// Dynamic max: prevents saturation at close zoom
|
|
||||||
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - zoom * 0.05));
|
|
||||||
return {
|
return {
|
||||||
radius: Math.round(radius),
|
radius: Math.round(radius),
|
||||||
blur: Math.round(blur),
|
blur: Math.round(blur),
|
||||||
maxIntensity,
|
maxIntensity: 1.0, // CONSTANT — zoom-independent colors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { useSitesStore } from '@/store/sites.ts';
|
|||||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
|
|
||||||
export default function BatchEdit() {
|
interface BatchEditProps {
|
||||||
|
onBatchApplied?: (affectedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchEdit({ onBatchApplied }: BatchEditProps) {
|
||||||
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
const selectedSiteIds = useSitesStore((s) => s.selectedSiteIds);
|
||||||
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
|
const batchUpdateHeight = useSitesStore((s) => s.batchUpdateHeight);
|
||||||
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
|
const batchSetHeight = useSitesStore((s) => s.batchSetHeight);
|
||||||
@@ -15,9 +19,11 @@ export default function BatchEdit() {
|
|||||||
if (selectedSiteIds.length === 0) return null;
|
if (selectedSiteIds.length === 0) return null;
|
||||||
|
|
||||||
const handleAdjustHeight = async (delta: number) => {
|
const handleAdjustHeight = async (delta: number) => {
|
||||||
|
const ids = [...selectedSiteIds];
|
||||||
await batchUpdateHeight(delta);
|
await batchUpdateHeight(delta);
|
||||||
|
onBatchApplied?.(ids);
|
||||||
addToast(
|
addToast(
|
||||||
`Adjusted ${selectedSiteIds.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
|
`Updated ${ids.length} site(s) by ${delta > 0 ? '+' : ''}${delta}m`,
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -28,8 +34,10 @@ export default function BatchEdit() {
|
|||||||
addToast('Height must be between 1-100m', 'error');
|
addToast('Height must be between 1-100m', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const ids = [...selectedSiteIds];
|
||||||
await batchSetHeight(height);
|
await batchSetHeight(height);
|
||||||
addToast(`Set ${selectedSiteIds.length} site(s) to ${height}m`, 'success');
|
onBatchApplied?.(ids);
|
||||||
|
addToast(`Set ${ids.length} site(s) to ${height}m`, 'success');
|
||||||
setCustomHeight('');
|
setCustomHeight('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,24 @@ export default function SiteForm({
|
|||||||
}
|
}
|
||||||
}, [pendingLocation]);
|
}, [pendingLocation]);
|
||||||
|
|
||||||
|
// Live-sync: update form when the edited site changes externally
|
||||||
|
// (e.g., batch height adjustment, drag on map)
|
||||||
|
useEffect(() => {
|
||||||
|
if (editSite) {
|
||||||
|
setHeight(editSite.height);
|
||||||
|
setPower(editSite.power);
|
||||||
|
setGain(editSite.gain);
|
||||||
|
setName(editSite.name);
|
||||||
|
setLat(editSite.lat);
|
||||||
|
setLon(editSite.lon);
|
||||||
|
setFrequency(editSite.frequency);
|
||||||
|
setAntennaType(editSite.antennaType);
|
||||||
|
setAzimuth(editSite.azimuth ?? 0);
|
||||||
|
setBeamwidth(editSite.beamwidth ?? 65);
|
||||||
|
setNotes(editSite.notes ?? '');
|
||||||
|
}
|
||||||
|
}, [editSite]);
|
||||||
|
|
||||||
const applyTemplate = (key: keyof typeof TEMPLATES) => {
|
const applyTemplate = (key: keyof typeof TEMPLATES) => {
|
||||||
const t = TEMPLATES[key];
|
const t = TEMPLATES[key];
|
||||||
setName(t.name);
|
setName(t.name);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { 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 { useToastStore } from '@/components/ui/Toast.tsx';
|
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||||
@@ -22,6 +23,15 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
const clearSelection = useSitesStore((s) => s.clearSelection);
|
const clearSelection = useSitesStore((s) => s.clearSelection);
|
||||||
const addToast = useToastStore((s) => s.addToast);
|
const addToast = useToastStore((s) => s.addToast);
|
||||||
|
|
||||||
|
// Track recently batch-updated site IDs for flash animation
|
||||||
|
const [flashIds, setFlashIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const triggerFlash = useCallback((ids: string[]) => {
|
||||||
|
setFlashIds(new Set(ids));
|
||||||
|
// Clear after animation completes
|
||||||
|
setTimeout(() => setFlashIds(new Set()), 700);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleDelete = async (id: string, name: string) => {
|
const handleDelete = async (id: string, name: string) => {
|
||||||
await deleteSite(id);
|
await deleteSite(id);
|
||||||
addToast(`"${name}" deleted`, 'info');
|
addToast(`"${name}" deleted`, 'info');
|
||||||
@@ -73,7 +83,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
{/* Batch edit panel (appears when sites are selected) */}
|
{/* Batch edit panel (appears when sites are selected) */}
|
||||||
{selectedSiteIds.length > 0 && (
|
{selectedSiteIds.length > 0 && (
|
||||||
<div className="px-3 pt-3">
|
<div className="px-3 pt-3">
|
||||||
<BatchEdit />
|
<BatchEdit onBatchApplied={triggerFlash} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -86,6 +96,7 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
<div className="divide-y divide-gray-100 dark:divide-dark-border max-h-60 overflow-y-auto">
|
<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);
|
const isBatchSelected = selectedSiteIds.includes(site.id);
|
||||||
|
const isFlashing = flashIds.has(site.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -93,7 +104,8 @@ export default function SiteList({ onEditSite, onAddSite }: SiteListProps) {
|
|||||||
className={`px-4 py-2.5 flex items-center gap-2.5 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
|
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' : ''}`}
|
${isBatchSelected && selectedSiteId !== site.id ? 'bg-blue-50/50 dark:bg-blue-900/10' : ''}
|
||||||
|
${isFlashing ? 'flash-update' : ''}`}
|
||||||
onClick={() => selectSite(site.id)}
|
onClick={() => selectSite(site.id)}
|
||||||
>
|
>
|
||||||
{/* Batch checkbox */}
|
{/* Batch checkbox */}
|
||||||
|
|||||||
@@ -41,3 +41,13 @@
|
|||||||
.dark .leaflet-tile-pane {
|
.dark .leaflet-tile-pane {
|
||||||
filter: brightness(0.8) contrast(1.1) saturate(0.8);
|
filter: brightness(0.8) contrast(1.1) saturate(0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flash animation for batch-updated sites */
|
||||||
|
@keyframes flash-update {
|
||||||
|
0%, 100% { background-color: transparent; }
|
||||||
|
50% { background-color: rgba(59, 130, 246, 0.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-update {
|
||||||
|
animation: flash-update 0.6s ease-in-out;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user