@mytec: iter8 ready for test
This commit is contained in:
@@ -7,8 +7,8 @@ import { RFCalculator } from '@/rf/calculator.ts';
|
||||
import { useToastStore } from '@/components/ui/Toast.tsx';
|
||||
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts.ts';
|
||||
import MapView from '@/components/map/Map.tsx';
|
||||
import Heatmap from '@/components/map/Heatmap.tsx';
|
||||
import Legend from '@/components/map/Legend.tsx';
|
||||
import GeographicHeatmap from '@/components/map/GeographicHeatmap.tsx';
|
||||
import HeatmapLegend from '@/components/map/HeatmapLegend.tsx';
|
||||
import SiteList from '@/components/panels/SiteList.tsx';
|
||||
import SiteForm from '@/components/panels/SiteForm.tsx';
|
||||
import ExportPanel from '@/components/panels/ExportPanel.tsx';
|
||||
@@ -261,14 +261,15 @@ export default function App() {
|
||||
<div className="flex-1 relative">
|
||||
<MapView onMapClick={handleMapClick} onEditSite={handleEditSite}>
|
||||
{coverageResult && (
|
||||
<Heatmap
|
||||
<GeographicHeatmap
|
||||
points={coverageResult.points}
|
||||
visible={heatmapVisible}
|
||||
opacity={settings.heatmapOpacity}
|
||||
radiusMeters={settings.heatmapRadius}
|
||||
/>
|
||||
)}
|
||||
</MapView>
|
||||
<Legend />
|
||||
<HeatmapLegend />
|
||||
</div>
|
||||
|
||||
{/* Side panel */}
|
||||
@@ -386,6 +387,27 @@ 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">
|
||||
Heatmap Quality: {settings.heatmapRadius}m
|
||||
</label>
|
||||
<select
|
||||
value={settings.heatmapRadius}
|
||||
onChange={(e) =>
|
||||
useCoverageStore
|
||||
.getState()
|
||||
.updateSettings({
|
||||
heatmapRadius: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
className="w-full mt-1 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-200 dark:border-dark-border rounded text-gray-700 dark:text-dark-text"
|
||||
>
|
||||
<option value={200}>200m — Fast</option>
|
||||
<option value={400}>400m — Balanced</option>
|
||||
<option value={600}>600m — Smooth</option>
|
||||
<option value={800}>800m — Wide</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">
|
||||
Terrain Opacity: {Math.round(terrainOpacity * 100)}%
|
||||
|
||||
209
frontend/src/components/map/GeographicHeatmap.tsx
Normal file
209
frontend/src/components/map/GeographicHeatmap.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Custom geographic-scale heatmap using Leaflet GridLayer + Canvas tiles.
|
||||
*
|
||||
* Replaces leaflet.heat with a renderer that maintains true geographic scale:
|
||||
* a 400m coverage radius is always 400m on the ground, regardless of zoom.
|
||||
*
|
||||
* Features:
|
||||
* - Tile-based rendering (256×256 canvas per tile)
|
||||
* - Automatic tile cache invalidation on point/radius changes
|
||||
* - Tile load progress reporting
|
||||
* - Configurable geographic radius
|
||||
* - Opacity control
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import L from 'leaflet';
|
||||
import { HeatmapTileRenderer } from './HeatmapTileRenderer.ts';
|
||||
import type { HeatmapPoint } from './HeatmapTileRenderer.ts';
|
||||
|
||||
interface GeographicHeatmapProps {
|
||||
points: HeatmapPoint[];
|
||||
visible: boolean;
|
||||
opacity?: number;
|
||||
radiusMeters?: number;
|
||||
}
|
||||
|
||||
export default function GeographicHeatmap({
|
||||
points,
|
||||
visible,
|
||||
opacity = 0.7,
|
||||
radiusMeters = 400,
|
||||
}: GeographicHeatmapProps) {
|
||||
const map = useMap();
|
||||
const layerRef = useRef<L.GridLayer | null>(null);
|
||||
const rendererRef = useRef<HeatmapTileRenderer>(
|
||||
new HeatmapTileRenderer(radiusMeters)
|
||||
);
|
||||
|
||||
// Tile progress
|
||||
const [tileProgress, setTileProgress] = useState<{
|
||||
loaded: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
// Stable reference to points for the tile factory closure
|
||||
const pointsRef = useRef<HeatmapPoint[]>(points);
|
||||
pointsRef.current = points;
|
||||
|
||||
// Update renderer radius when prop changes
|
||||
useEffect(() => {
|
||||
rendererRef.current.setRadiusMeters(radiusMeters);
|
||||
}, [radiusMeters]);
|
||||
|
||||
// Invalidate cache when points change (use length + first/last coords as fingerprint)
|
||||
useEffect(() => {
|
||||
if (points.length === 0) {
|
||||
rendererRef.current.setPointsHash('empty');
|
||||
return;
|
||||
}
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
const hash = `${points.length}:${first.lat.toFixed(4)},${first.lon.toFixed(4)}:${last.rsrp}`;
|
||||
rendererRef.current.setPointsHash(hash);
|
||||
}, [points]);
|
||||
|
||||
// Create / destroy GridLayer
|
||||
const createLayer = useCallback(() => {
|
||||
// Remove existing
|
||||
if (layerRef.current) {
|
||||
map.removeLayer(layerRef.current);
|
||||
layerRef.current = null;
|
||||
}
|
||||
|
||||
if (!visible || points.length === 0) {
|
||||
setTileProgress(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const renderer = rendererRef.current;
|
||||
const currentPointsRef = pointsRef;
|
||||
|
||||
// Custom GridLayer with canvas tiles
|
||||
const HeatmapGridLayer = L.GridLayer.extend({
|
||||
createTile(
|
||||
this: L.GridLayer,
|
||||
coords: L.Coords,
|
||||
done: (err: Error | null, tile: HTMLElement) => void
|
||||
) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
// Use requestAnimationFrame to avoid blocking the main thread
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
renderer.renderTile(
|
||||
canvas,
|
||||
currentPointsRef.current,
|
||||
coords.x,
|
||||
coords.y,
|
||||
coords.z
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Tile render error:', error);
|
||||
// Draw error indicator
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
ctx.fillStyle = 'rgba(255,0,0,0.05)';
|
||||
ctx.fillRect(0, 0, 256, 256);
|
||||
}
|
||||
}
|
||||
done(null, canvas);
|
||||
});
|
||||
|
||||
return canvas;
|
||||
},
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const layer = new (HeatmapGridLayer as any)({
|
||||
opacity,
|
||||
zIndex: 200,
|
||||
updateWhenZooming: false,
|
||||
updateWhenIdle: true,
|
||||
}) as L.GridLayer;
|
||||
|
||||
// Track tile loading progress
|
||||
let total = 0;
|
||||
let loaded = 0;
|
||||
|
||||
layer.on('tileloadstart', () => {
|
||||
total++;
|
||||
setTileProgress({ loaded, total });
|
||||
});
|
||||
|
||||
layer.on('tileload', () => {
|
||||
loaded++;
|
||||
setTileProgress({ loaded, total });
|
||||
});
|
||||
|
||||
layer.on('load', () => {
|
||||
// All tiles done — hide progress after short delay
|
||||
setTimeout(() => setTileProgress(null), 500);
|
||||
total = 0;
|
||||
loaded = 0;
|
||||
});
|
||||
|
||||
layer.addTo(map);
|
||||
layerRef.current = layer;
|
||||
}, [map, visible, points, opacity, radiusMeters]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Main effect: create/recreate layer
|
||||
useEffect(() => {
|
||||
createLayer();
|
||||
return () => {
|
||||
if (layerRef.current) {
|
||||
map.removeLayer(layerRef.current);
|
||||
layerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [createLayer, map]);
|
||||
|
||||
// Opacity changes without full recreate
|
||||
useEffect(() => {
|
||||
if (layerRef.current) {
|
||||
layerRef.current.setOpacity(opacity);
|
||||
}
|
||||
}, [opacity]);
|
||||
|
||||
// Render tile progress indicator
|
||||
if (tileProgress && tileProgress.total > 0 && tileProgress.loaded < tileProgress.total) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
color: 'white',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: '2px solid rgba(255,255,255,0.3)',
|
||||
borderTopColor: 'white',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
/>
|
||||
Rendering {tileProgress.loaded}/{tileProgress.total} tiles
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
108
frontend/src/components/map/HeatmapLegend.tsx
Normal file
108
frontend/src/components/map/HeatmapLegend.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Color-accurate RF signal legend that uses the exact same
|
||||
* gradient pipeline as the heatmap renderer.
|
||||
*
|
||||
* Renders a smooth continuous gradient bar + labeled RSRP thresholds.
|
||||
*/
|
||||
|
||||
import { normalizeRSRP, valueToColor } from '@/utils/colorGradient.ts';
|
||||
import { useCoverageStore } from '@/store/coverage.ts';
|
||||
import { useSitesStore } from '@/store/sites.ts';
|
||||
|
||||
const LEGEND_STEPS = [
|
||||
{ rsrp: -130, label: 'No Service' },
|
||||
{ rsrp: -110, label: 'Very Weak' },
|
||||
{ rsrp: -100, label: 'Weak' },
|
||||
{ rsrp: -90, label: 'Fair' },
|
||||
{ rsrp: -80, label: 'Good' },
|
||||
{ rsrp: -70, label: 'Strong' },
|
||||
{ rsrp: -50, label: 'Excellent' },
|
||||
];
|
||||
|
||||
/** Build a CSS linear-gradient string matching the heatmap gradient exactly. */
|
||||
function buildGradientCSS(): string {
|
||||
const stops: string[] = [];
|
||||
const steps = 20;
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const value = i / steps; // 0–1
|
||||
const [r, g, b] = valueToColor(value);
|
||||
const pct = (i / steps) * 100;
|
||||
stops.push(`rgb(${r},${g},${b}) ${pct.toFixed(0)}%`);
|
||||
}
|
||||
return `linear-gradient(to top, ${stops.join(', ')})`;
|
||||
}
|
||||
|
||||
const gradientCSS = buildGradientCSS();
|
||||
|
||||
export default function HeatmapLegend() {
|
||||
const result = useCoverageStore((s) => s.result);
|
||||
const heatmapVisible = useCoverageStore((s) => s.heatmapVisible);
|
||||
const toggleHeatmap = useCoverageStore((s) => s.toggleHeatmap);
|
||||
const settings = useCoverageStore((s) => s.settings);
|
||||
const sites = useSitesStore((s) => s.sites);
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const areaKm2 =
|
||||
(result.totalPoints * settings.resolution * settings.resolution) / 1e6;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-6 right-2 z-[1000] bg-white dark:bg-dark-surface rounded-lg shadow-lg border border-gray-200 dark:border-dark-border p-3 min-w-[180px]">
|
||||
{/* Header with toggle */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xs font-semibold text-gray-700 dark:text-dark-text">
|
||||
Signal (RSRP)
|
||||
</h3>
|
||||
<button
|
||||
onClick={toggleHeatmap}
|
||||
className={`w-8 h-4 rounded-full transition-colors relative
|
||||
${heatmapVisible ? 'bg-blue-500' : 'bg-gray-300 dark:bg-dark-border'}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-3 h-3 rounded-full bg-white shadow transition-transform
|
||||
${heatmapVisible ? 'left-4' : 'left-0.5'}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Gradient bar + labels */}
|
||||
<div className="flex gap-2">
|
||||
{/* Continuous gradient bar */}
|
||||
<div
|
||||
className="w-3 rounded-sm flex-shrink-0"
|
||||
style={{
|
||||
background: gradientCSS,
|
||||
minHeight: `${LEGEND_STEPS.length * 18}px`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex flex-col justify-between flex-1 py-0.5">
|
||||
{[...LEGEND_STEPS].reverse().map((step) => {
|
||||
const norm = normalizeRSRP(step.rsrp);
|
||||
const [r, g, b] = valueToColor(norm);
|
||||
return (
|
||||
<div key={step.rsrp} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: `rgb(${r},${g},${b})` }}
|
||||
/>
|
||||
<span className="text-[10px] text-gray-600 dark:text-gray-400 leading-tight">
|
||||
{step.rsrp} dBm {step.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-100 dark:border-dark-border text-[10px] text-gray-400 dark:text-dark-muted space-y-0.5">
|
||||
<div>Points: {result.totalPoints.toLocaleString()}</div>
|
||||
<div>Time: {(result.calculationTime / 1000).toFixed(2)}s</div>
|
||||
<div>Area: ~{areaKm2.toFixed(1)} km²</div>
|
||||
<div>Sites: {sites.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
212
frontend/src/components/map/HeatmapTileRenderer.ts
Normal file
212
frontend/src/components/map/HeatmapTileRenderer.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Custom Canvas tile renderer for geographic-scale RF coverage heatmap.
|
||||
*
|
||||
* Each coverage point is rendered with a fixed geographic radius (default 400m),
|
||||
* which means the same RSRP always maps to the same color regardless of zoom.
|
||||
*
|
||||
* Features:
|
||||
* - True geographic scale (400m = 400m at every zoom)
|
||||
* - Gaussian falloff for smooth point edges
|
||||
* - LRU tile cache (configurable size)
|
||||
* - Performance monitoring (dev mode)
|
||||
* - "Max wins" blending — overlapping points show the strongest signal
|
||||
*/
|
||||
|
||||
import {
|
||||
metersToPixels,
|
||||
latLonToTilePixel,
|
||||
getTileBounds,
|
||||
} from '@/utils/geographicScale.ts';
|
||||
import {
|
||||
normalizeRSRP,
|
||||
valueToColorRGB,
|
||||
gaussianWeight,
|
||||
} from '@/utils/colorGradient.ts';
|
||||
|
||||
export interface HeatmapPoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
siteId: string;
|
||||
}
|
||||
|
||||
export class HeatmapTileRenderer {
|
||||
private tileSize = 256;
|
||||
private radiusMeters: number;
|
||||
|
||||
// LRU cache: key → canvas
|
||||
private cache = new Map<string, ImageData>();
|
||||
private maxCacheSize: number;
|
||||
|
||||
// Points fingerprint for cache invalidation
|
||||
private pointsHash = '';
|
||||
|
||||
constructor(radiusMeters = 400, maxCacheSize = 150) {
|
||||
this.radiusMeters = radiusMeters;
|
||||
this.maxCacheSize = maxCacheSize;
|
||||
}
|
||||
|
||||
/** Update the geographic radius of each coverage point. */
|
||||
setRadiusMeters(r: number): void {
|
||||
if (r !== this.radiusMeters) {
|
||||
this.radiusMeters = r;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
/** Call when points change to invalidate cache. */
|
||||
setPointsHash(hash: string): void {
|
||||
if (hash !== this.pointsHash) {
|
||||
this.pointsHash = hash;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single 256×256 tile.
|
||||
*
|
||||
* Returns true if anything was drawn, false for empty tiles.
|
||||
*/
|
||||
renderTile(
|
||||
canvas: HTMLCanvasElement,
|
||||
points: HeatmapPoint[],
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
zoom: number
|
||||
): boolean {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return false;
|
||||
|
||||
canvas.width = this.tileSize;
|
||||
canvas.height = this.tileSize;
|
||||
ctx.clearRect(0, 0, this.tileSize, this.tileSize);
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `${tileX}:${tileY}:${zoom}`;
|
||||
const cached = this.cache.get(cacheKey);
|
||||
if (cached) {
|
||||
ctx.putImageData(cached, 0, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tile geographic bounds
|
||||
const [[latMin, lonMin], [latMax, lonMax]] = getTileBounds(
|
||||
tileX,
|
||||
tileY,
|
||||
zoom
|
||||
);
|
||||
|
||||
// Buffer in degrees for the radius so edge points outside the tile
|
||||
// still contribute their gaussian tail inside the tile
|
||||
const bufferDeg = (this.radiusMeters / 111_000) * 2;
|
||||
|
||||
// Filter relevant points
|
||||
const relevant = points.filter(
|
||||
(p) =>
|
||||
p.lat >= latMin - bufferDeg &&
|
||||
p.lat <= latMax + bufferDeg &&
|
||||
p.lon >= lonMin - bufferDeg &&
|
||||
p.lon <= lonMax + bufferDeg
|
||||
);
|
||||
|
||||
if (relevant.length === 0) return false;
|
||||
|
||||
const t0 = import.meta.env.DEV ? performance.now() : 0;
|
||||
|
||||
// Accumulation buffer: per-pixel best (max) normalized RSRP weighted by gaussian
|
||||
const size = this.tileSize;
|
||||
const buf = new Float32Array(size * size); // max weighted intensity per pixel
|
||||
|
||||
for (const pt of relevant) {
|
||||
const [cx, cy] = latLonToTilePixel(pt.lat, pt.lon, tileX, tileY, zoom);
|
||||
const rPx = metersToPixels(this.radiusMeters, pt.lat, zoom);
|
||||
const normVal = normalizeRSRP(pt.rsrp);
|
||||
|
||||
// Bounding box in pixels (clamped to tile)
|
||||
const x0 = Math.max(0, Math.floor(cx - rPx));
|
||||
const x1 = Math.min(size, Math.ceil(cx + rPx));
|
||||
const y0 = Math.max(0, Math.floor(cy - rPx));
|
||||
const y1 = Math.min(size, Math.ceil(cy + rPx));
|
||||
|
||||
if (x0 >= size || x1 <= 0 || y0 >= size || y1 <= 0) continue;
|
||||
|
||||
const rPxSq = rPx * rPx;
|
||||
|
||||
for (let y = y0; y < y1; y++) {
|
||||
const dy = y - cy;
|
||||
const dySq = dy * dy;
|
||||
const rowOff = y * size;
|
||||
|
||||
for (let x = x0; x < x1; x++) {
|
||||
const dx = x - cx;
|
||||
const distSq = dx * dx + dySq;
|
||||
|
||||
if (distSq > rPxSq) continue;
|
||||
|
||||
const dist = Math.sqrt(distSq);
|
||||
const weight = gaussianWeight(dist, rPx);
|
||||
const intensity = normVal * weight;
|
||||
|
||||
const idx = rowOff + x;
|
||||
// "Max wins" — strongest signal wins at each pixel
|
||||
if (intensity > buf[idx]) {
|
||||
buf[idx] = intensity;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render to ImageData
|
||||
const imageData = ctx.createImageData(size, size);
|
||||
const data = imageData.data;
|
||||
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const val = buf[i];
|
||||
if (val <= 0) continue;
|
||||
|
||||
const clamped = Math.min(1, val);
|
||||
const [r, g, b] = valueToColorRGB(clamped);
|
||||
|
||||
// Alpha: full opacity for strong signals, fade at weak edges
|
||||
const alpha = Math.min(255, Math.round(clamped * 300));
|
||||
|
||||
const off = i * 4;
|
||||
data[off] = r;
|
||||
data[off + 1] = g;
|
||||
data[off + 2] = b;
|
||||
data[off + 3] = alpha;
|
||||
}
|
||||
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Cache the rendered tile
|
||||
this.cacheStore(cacheKey, imageData);
|
||||
|
||||
// Dev perf log
|
||||
if (import.meta.env.DEV) {
|
||||
const ms = performance.now() - t0;
|
||||
if (ms > 50) {
|
||||
console.log(
|
||||
`🖌️ Tile ${tileX},${tileY} z${zoom}: ${ms.toFixed(1)}ms (${relevant.length} pts)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private cacheStore(key: string, data: ImageData): void {
|
||||
// Evict oldest if full
|
||||
if (this.cache.size >= this.maxCacheSize) {
|
||||
const firstKey = this.cache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
this.cache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
this.cache.set(key, data);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
|
||||
resolution: 200,
|
||||
rsrpThreshold: -120,
|
||||
heatmapOpacity: 0.7,
|
||||
heatmapRadius: 400,
|
||||
},
|
||||
heatmapVisible: true,
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CoverageSettings {
|
||||
resolution: number; // meters (grid resolution)
|
||||
rsrpThreshold: number; // dBm (minimum signal to display)
|
||||
heatmapOpacity: number; // 0.3-1.0 (heatmap layer opacity)
|
||||
heatmapRadius: number; // meters (coverage point visual radius, 200/400/600)
|
||||
}
|
||||
|
||||
export interface GridPoint {
|
||||
|
||||
117
frontend/src/utils/colorGradient.ts
Normal file
117
frontend/src/utils/colorGradient.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* RSRP → color mapping with smooth gradient interpolation.
|
||||
*
|
||||
* Gradient stops are chosen to match standard RF planning tools:
|
||||
* -130 dBm = deep blue (no service)
|
||||
* -90 dBm = green (fair)
|
||||
* -50 dBm = red (excellent)
|
||||
*
|
||||
* All functions are pure and allocation-free on the hot path
|
||||
* (pre-built lookup table for fast per-pixel color resolution).
|
||||
*/
|
||||
|
||||
interface GradientStop {
|
||||
value: number; // normalized 0–1
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
const GRADIENT_STOPS: GradientStop[] = [
|
||||
{ value: 0.0, r: 26, g: 35, b: 126 }, // #1a237e — deep blue
|
||||
{ value: 0.15, r: 13, g: 71, b: 161 }, // #0d47a1
|
||||
{ value: 0.25, r: 33, g: 150, b: 243 }, // #2196f3 — blue
|
||||
{ value: 0.35, r: 0, g: 188, b: 212 }, // #00bcd4 — cyan
|
||||
{ value: 0.45, r: 0, g: 137, b: 123 }, // #00897b — teal
|
||||
{ value: 0.55, r: 76, g: 175, b: 80 }, // #4caf50 — green
|
||||
{ value: 0.65, r: 139, g: 195, b: 74 }, // #8bc34a — light green
|
||||
{ value: 0.75, r: 255, g: 235, b: 59 }, // #ffeb3b — yellow
|
||||
{ value: 0.85, r: 255, g: 152, b: 0 }, // #ff9800 — orange
|
||||
{ value: 1.0, r: 244, g: 67, b: 54 }, // #f44336 — red
|
||||
];
|
||||
|
||||
/**
|
||||
* Pre-built 256-entry lookup table for O(1) color resolution.
|
||||
* Each entry is [r, g, b].
|
||||
*/
|
||||
const COLOR_LUT: Uint8Array = buildColorLUT();
|
||||
|
||||
function buildColorLUT(): Uint8Array {
|
||||
const size = 256;
|
||||
const lut = new Uint8Array(size * 3);
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
const value = i / (size - 1); // 0–1
|
||||
const [r, g, b] = interpolateGradient(value);
|
||||
lut[i * 3] = r;
|
||||
lut[i * 3 + 1] = g;
|
||||
lut[i * 3 + 2] = b;
|
||||
}
|
||||
|
||||
return lut;
|
||||
}
|
||||
|
||||
function interpolateGradient(value: number): [number, number, number] {
|
||||
// Find surrounding stops
|
||||
let lower = GRADIENT_STOPS[0];
|
||||
let upper = GRADIENT_STOPS[GRADIENT_STOPS.length - 1];
|
||||
|
||||
for (let i = 0; i < GRADIENT_STOPS.length - 1; i++) {
|
||||
if (value >= GRADIENT_STOPS[i].value && value <= GRADIENT_STOPS[i + 1].value) {
|
||||
lower = GRADIENT_STOPS[i];
|
||||
upper = GRADIENT_STOPS[i + 1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const range = upper.value - lower.value;
|
||||
const t = range > 0 ? (value - lower.value) / range : 0;
|
||||
|
||||
return [
|
||||
Math.round(lower.r + (upper.r - lower.r) * t),
|
||||
Math.round(lower.g + (upper.g - lower.g) * t),
|
||||
Math.round(lower.b + (upper.b - lower.b) * t),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSRP (dBm) to 0–1 range.
|
||||
*
|
||||
* -130 dBm → 0.0
|
||||
* -50 dBm → 1.0
|
||||
*/
|
||||
export function normalizeRSRP(rsrp: number): number {
|
||||
const minRSRP = -130;
|
||||
const maxRSRP = -50;
|
||||
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
|
||||
return Math.max(0, Math.min(1, normalized));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast color lookup from normalized value (0–1).
|
||||
* Uses pre-built LUT — O(1), no allocations.
|
||||
*
|
||||
* Returns [r, g, b] (0–255 each).
|
||||
*/
|
||||
export function valueToColorRGB(value: number): [number, number, number] {
|
||||
const idx = Math.max(0, Math.min(255, Math.round(value * 255))) * 3;
|
||||
return [COLOR_LUT[idx], COLOR_LUT[idx + 1], COLOR_LUT[idx + 2]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Full gradient interpolation (slower, for non-hot-path use like legends).
|
||||
*/
|
||||
export function valueToColor(value: number): [number, number, number] {
|
||||
return interpolateGradient(Math.max(0, Math.min(1, value)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Gaussian falloff for smooth point edges.
|
||||
*
|
||||
* Returns 0–1 weight based on distance from point center.
|
||||
* sigma defaults to radius/3 for a natural falloff.
|
||||
*/
|
||||
export function gaussianWeight(distance: number, radius: number, sigma?: number): number {
|
||||
const s = sigma ?? radius / 3;
|
||||
return Math.exp(-(distance * distance) / (2 * s * s));
|
||||
}
|
||||
81
frontend/src/utils/geographicScale.ts
Normal file
81
frontend/src/utils/geographicScale.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Geographic scale utilities for Web Mercator (EPSG:3857) projection.
|
||||
*
|
||||
* Provides accurate meters ↔ pixels conversions, tile bounds,
|
||||
* and lat/lon → tile pixel coordinate transforms.
|
||||
*/
|
||||
|
||||
const EARTH_CIRCUMFERENCE_M = 40075017;
|
||||
|
||||
/**
|
||||
* Calculate pixels per meter at given latitude and zoom level.
|
||||
* Uses the Web Mercator distortion factor: cos(lat).
|
||||
*/
|
||||
export function getPixelsPerMeter(lat: number, zoom: number): number {
|
||||
const tileSize = 256;
|
||||
const numTiles = Math.pow(2, zoom);
|
||||
const worldWidthPixels = tileSize * numTiles;
|
||||
const metersPerPixelEquator = EARTH_CIRCUMFERENCE_M / worldWidthPixels;
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const metersPerPixel = metersPerPixelEquator * Math.cos(latRad);
|
||||
return 1 / metersPerPixel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert geographic distance (meters) to pixel distance at given zoom/latitude.
|
||||
*/
|
||||
export function metersToPixels(meters: number, lat: number, zoom: number): number {
|
||||
return meters * getPixelsPerMeter(lat, zoom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tile geographic bounds (lat/lon) for given tile coordinates.
|
||||
* Returns [[latMin, lonMin], [latMax, lonMax]].
|
||||
*/
|
||||
export function getTileBounds(
|
||||
x: number,
|
||||
y: number,
|
||||
zoom: number
|
||||
): [[number, number], [number, number]] {
|
||||
const n = Math.pow(2, zoom);
|
||||
|
||||
const lonMin = (x / n) * 360 - 180;
|
||||
const lonMax = ((x + 1) / n) * 360 - 180;
|
||||
|
||||
const latMin =
|
||||
(Math.atan(Math.sinh(Math.PI * (1 - (2 * (y + 1)) / n))) * 180) / Math.PI;
|
||||
const latMax =
|
||||
(Math.atan(Math.sinh(Math.PI * (1 - (2 * y) / n))) * 180) / Math.PI;
|
||||
|
||||
return [
|
||||
[latMin, lonMin],
|
||||
[latMax, lonMax],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lon to pixel position within a specific tile.
|
||||
* Returns [pixelX, pixelY] relative to tile's top-left corner.
|
||||
*/
|
||||
export function latLonToTilePixel(
|
||||
lat: number,
|
||||
lon: number,
|
||||
tileX: number,
|
||||
tileY: number,
|
||||
zoom: number
|
||||
): [number, number] {
|
||||
const n = Math.pow(2, zoom);
|
||||
|
||||
// Point's world-tile coordinates
|
||||
const worldX = ((lon + 180) / 360) * n;
|
||||
const latRad = (lat * Math.PI) / 180;
|
||||
const worldY =
|
||||
((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) *
|
||||
n;
|
||||
|
||||
// Offset within this tile (in pixels)
|
||||
const pixelX = (worldX - tileX) * 256;
|
||||
const pixelY = (worldY - tileY) * 256;
|
||||
|
||||
return [pixelX, pixelY];
|
||||
}
|
||||
Reference in New Issue
Block a user