@mytec: iteration3 done for testing

This commit is contained in:
2026-01-30 11:37:38 +02:00
parent d3fb1801a8
commit 8b11163a79
8 changed files with 498 additions and 2 deletions

View File

@@ -11,6 +11,8 @@ import Heatmap from '@/components/map/Heatmap.tsx';
import Legend from '@/components/map/Legend.tsx';
import SiteList from '@/components/panels/SiteList.tsx';
import SiteForm from '@/components/panels/SiteForm.tsx';
import ExportPanel from '@/components/panels/ExportPanel.tsx';
import ProjectPanel from '@/components/panels/ProjectPanel.tsx';
import ToastContainer from '@/components/ui/Toast.tsx';
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
import Button from '@/components/ui/Button.tsx';
@@ -248,6 +250,7 @@ export default function App() {
<Heatmap
points={coverageResult.points}
visible={heatmapVisible}
opacity={settings.heatmapOpacity}
/>
)}
</MapView>
@@ -349,8 +352,34 @@ 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 Opacity: {Math.round(settings.heatmapOpacity * 100)}%
</label>
<input
type="range"
min={0.3}
max={1.0}
step={0.1}
value={settings.heatmapOpacity}
onChange={(e) =>
useCoverageStore
.getState()
.updateSettings({
heatmapOpacity: Number(e.target.value),
})
}
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>
{/* Export coverage data */}
<ExportPanel />
{/* Projects save/load */}
<ProjectPanel />
</div>
</div>
</div>

View File

@@ -15,6 +15,7 @@ declare module 'leaflet' {
interface HeatmapProps {
points: CoveragePoint[];
visible: boolean;
opacity?: number;
}
/**
@@ -52,7 +53,7 @@ function getHeatmapParams(zoom: number) {
};
}
export default function Heatmap({ points, visible }: HeatmapProps) {
export default function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
const map = useMap();
const [mapZoom, setMapZoom] = useState(map.getZoom());
@@ -98,10 +99,16 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
heatLayer.addTo(map);
// Apply opacity to the canvas element
const container = (heatLayer as unknown as { _canvas?: HTMLCanvasElement })._canvas;
if (container) {
container.style.opacity = String(opacity);
}
return () => {
map.removeLayer(heatLayer);
};
}, [map, points, visible, mapZoom]);
}, [map, points, visible, mapZoom, opacity]);
return null;
}

View File

@@ -0,0 +1,92 @@
import { useCoverageStore } from '@/store/coverage.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
export default function ExportPanel() {
const result = useCoverageStore((s) => s.result);
const addToast = useToastStore((s) => s.addToast);
const points = result?.points ?? [];
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const exportCSV = () => {
if (points.length === 0) {
addToast('No coverage data to export. Run a calculation first.', 'error');
return;
}
const csv = [
'lat,lon,rsrp,site_id',
...points.map((p) => `${p.lat},${p.lon},${p.rsrp},${p.siteId}`),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
downloadBlob(blob, `rfcp-coverage-${Date.now()}.csv`);
addToast(`Exported ${points.length.toLocaleString()} points as CSV`, 'success');
};
const exportGeoJSON = () => {
if (points.length === 0) {
addToast('No coverage data to export. Run a calculation first.', 'error');
return;
}
const geojson = {
type: 'FeatureCollection' as const,
features: points.map((p) => ({
type: 'Feature' as const,
geometry: {
type: 'Point' as const,
coordinates: [p.lon, p.lat],
},
properties: {
rsrp: p.rsrp,
siteId: p.siteId,
},
})),
};
const blob = new Blob([JSON.stringify(geojson, null, 2)], {
type: 'application/geo+json;charset=utf-8',
});
downloadBlob(blob, `rfcp-coverage-${Date.now()}.geojson`);
addToast(`Exported ${points.length.toLocaleString()} points as GeoJSON`, 'success');
};
return (
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Export Coverage
</h3>
{points.length > 0 ? (
<>
<p className="text-xs text-gray-500 dark:text-dark-muted">
{points.length.toLocaleString()} coverage points available
</p>
<div className="flex gap-2">
<Button onClick={exportCSV} size="sm" variant="secondary">
CSV
</Button>
<Button onClick={exportGeoJSON} size="sm" variant="secondary">
GeoJSON
</Button>
</div>
</>
) : (
<p className="text-xs text-gray-400 dark:text-dark-muted">
No coverage data. Calculate coverage first to enable export.
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { useEffect, useState } from 'react';
import { useProjectsStore } from '@/store/projects.ts';
import { useToastStore } from '@/components/ui/Toast.tsx';
import Button from '@/components/ui/Button.tsx';
export default function ProjectPanel() {
const projects = useProjectsStore((s) => s.projects);
const currentProjectId = useProjectsStore((s) => s.currentProjectId);
const loadProjects = useProjectsStore((s) => s.loadProjects);
const saveProject = useProjectsStore((s) => s.saveProject);
const loadProject = useProjectsStore((s) => s.loadProject);
const deleteProject = useProjectsStore((s) => s.deleteProject);
const addToast = useToastStore((s) => s.addToast);
const [projectName, setProjectName] = useState('');
const [showSaveForm, setShowSaveForm] = useState(false);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
loadProjects();
}, [loadProjects]);
const handleSave = async () => {
const name = projectName.trim();
if (!name) {
addToast('Please enter a project name', 'error');
return;
}
try {
await saveProject(name);
addToast(`Project "${name}" saved`, 'success');
setProjectName('');
setShowSaveForm(false);
} catch (err) {
console.error('Save project error:', err);
addToast('Failed to save project', 'error');
}
};
const handleLoad = async (id: string, name: string) => {
setIsLoading(true);
try {
const project = await loadProject(id);
if (project) {
addToast(`Loaded project "${name}" (${project.sites.length} sites)`, 'success');
} else {
addToast('Project not found', 'error');
}
} catch (err) {
console.error('Load project error:', err);
addToast('Failed to load project', 'error');
} finally {
setIsLoading(false);
}
};
const handleDelete = async (id: string, name: string) => {
try {
await deleteProject(id);
addToast(`Project "${name}" deleted`, 'info');
} catch (err) {
console.error('Delete project error:', err);
addToast('Failed to delete project', 'error');
}
};
const formatDate = (timestamp: number) => {
return new Date(timestamp).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="bg-white dark:bg-dark-surface border border-gray-200 dark:border-dark-border rounded-lg shadow-sm p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-800 dark:text-dark-text">
Projects
</h3>
<Button
size="sm"
variant={showSaveForm ? 'secondary' : 'primary'}
onClick={() => setShowSaveForm(!showSaveForm)}
>
{showSaveForm ? 'Cancel' : 'Save Current'}
</Button>
</div>
{/* Save form */}
{showSaveForm && (
<div className="space-y-2">
<input
type="text"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
placeholder="Project name"
className="w-full px-3 py-1.5 border border-gray-300 dark:border-dark-border dark:bg-dark-bg dark:text-dark-text rounded-md text-sm
focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
<Button size="sm" onClick={handleSave} disabled={!projectName.trim()}>
Save Project
</Button>
</div>
)}
{/* Projects list */}
{projects.length === 0 ? (
<p className="text-xs text-gray-400 dark:text-dark-muted">
No saved projects yet.
</p>
) : (
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{projects.map((project) => (
<div
key={project.id}
className={`flex items-center justify-between gap-2 px-2.5 py-2 rounded-md transition-colors
${
currentProjectId === project.id
? 'bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800'
: 'bg-gray-50 dark:bg-dark-border/30 hover:bg-gray-100 dark:hover:bg-dark-border/50'
}`}
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 dark:text-dark-text truncate">
{project.name}
{currentProjectId === project.id && (
<span className="ml-1.5 text-xs text-blue-500 dark:text-blue-400">
(active)
</span>
)}
</div>
<div className="text-xs text-gray-500 dark:text-dark-muted">
{project.sites.length} site{project.sites.length !== 1 ? 's' : ''} &middot;{' '}
{formatDate(project.updatedAt)}
</div>
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => handleLoad(project.id, project.name)}
disabled={isLoading}
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded
min-w-[40px] min-h-[32px] flex items-center justify-center disabled:opacity-50"
>
Load
</button>
<button
onClick={() => handleDelete(project.id, project.name)}
className="px-2 py-1 text-xs text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded
min-w-[32px] min-h-[32px] flex items-center justify-center"
>
&times;
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
/**
* Terrain Service Phase 4 stub.
*
* Provides elevation queries that future phases will use for:
* - Line-of-sight (LOS) / non-LOS classification
* - Diffraction loss (knife-edge / Bullington)
* - Terrain-aware heatmaps
*
* For now we expose the interface and a flat-terrain mock.
* Switch to BackendTerrainService once the FastAPI `/api/terrain/*`
* endpoints are ready with real SRTM / DEM data.
*/
export interface TerrainService {
/** Return ground elevation (metres above sea level) at a point. */
getElevation(lat: number, lon: number): Promise<number>;
/**
* Return an elevation profile between two points.
* @param samples Number of equidistant samples along the path.
* @returns Array of elevations (metres) from start to end.
*/
getElevationProfile(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
samples: number,
): Promise<number[]>;
}
// ---------------------------------------------------------------------------
// Mock flat terrain (elevation 0 everywhere)
// ---------------------------------------------------------------------------
export class MockTerrainService implements TerrainService {
async getElevation(_lat: number, _lon: number): Promise<number> {
return 0;
}
async getElevationProfile(
_lat1: number,
_lon1: number,
_lat2: number,
_lon2: number,
samples: number,
): Promise<number[]> {
return Array(samples).fill(0);
}
}
// ---------------------------------------------------------------------------
// Backend calls FastAPI terrain endpoints (Phase 4+)
// ---------------------------------------------------------------------------
export class BackendTerrainService implements TerrainService {
private apiUrl: string;
constructor(apiUrl: string) {
this.apiUrl = apiUrl;
}
async getElevation(lat: number, lon: number): Promise<number> {
const response = await fetch(
`${this.apiUrl}/api/terrain/elevation?lat=${lat}&lon=${lon}`,
);
if (!response.ok) throw new Error(`Terrain API error: ${response.status}`);
const data = await response.json();
return data.elevation;
}
async getElevationProfile(
lat1: number,
lon1: number,
lat2: number,
lon2: number,
samples: number,
): Promise<number[]> {
const response = await fetch(
`${this.apiUrl}/api/terrain/profile?lat1=${lat1}&lon1=${lon1}&lat2=${lat2}&lon2=${lon2}&samples=${samples}`,
);
if (!response.ok) throw new Error(`Terrain API error: ${response.status}`);
const data = await response.json();
return data.profile;
}
}
// ---------------------------------------------------------------------------
// Default export use Mock until backend is ready
// ---------------------------------------------------------------------------
export const terrainService: TerrainService = new MockTerrainService();

View File

@@ -21,6 +21,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
radius: 10,
resolution: 200,
rsrpThreshold: -120,
heatmapOpacity: 0.7,
},
heatmapVisible: true,

View File

@@ -0,0 +1,112 @@
import { create } from 'zustand';
import { v4 as uuidv4 } from 'uuid';
import { db } from '@/db/schema.ts';
import { useSitesStore } from '@/store/sites.ts';
import { useCoverageStore } from '@/store/coverage.ts';
import type { Site, CoverageSettings } from '@/types/index.ts';
export interface Project {
id: string;
name: string;
description?: string;
sites: Site[];
coverageSettings: CoverageSettings;
createdAt: number;
updatedAt: number;
}
interface ProjectsState {
projects: Project[];
currentProjectId: string | null;
loadProjects: () => Promise<void>;
saveProject: (name: string, description?: string) => Promise<void>;
loadProject: (id: string) => Promise<Project | null>;
deleteProject: (id: string) => Promise<void>;
}
export const useProjectsStore = create<ProjectsState>((set, get) => ({
projects: [],
currentProjectId: null,
loadProjects: async () => {
const dbProjects = await db.projects.orderBy('updatedAt').reverse().toArray();
const projects: Project[] = dbProjects.map((p) => JSON.parse(p.data));
set({ projects });
},
saveProject: async (name: string, description?: string) => {
const sites = useSitesStore.getState().sites;
const coverageSettings = useCoverageStore.getState().settings;
const now = Date.now();
// Check if updating existing project with same name
const existing = get().projects.find((p) => p.name === name);
const id = existing?.id ?? uuidv4();
const project: Project = {
id,
name,
description,
sites,
coverageSettings,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
await db.projects.put({
id,
name,
data: JSON.stringify(project),
createdAt: project.createdAt,
updatedAt: now,
});
set({ currentProjectId: id });
await get().loadProjects();
},
loadProject: async (id: string) => {
const dbProject = await db.projects.get(id);
if (!dbProject) return null;
const project: Project = JSON.parse(dbProject.data);
// Restore sites: clear existing and add project sites
const sitesStore = useSitesStore.getState();
// Clear all existing sites from DB
const currentSites = sitesStore.sites;
for (const site of currentSites) {
await sitesStore.deleteSite(site.id);
}
// Re-add project sites
for (const site of project.sites) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: new Date(site.createdAt).getTime(),
updatedAt: new Date(site.updatedAt).getTime(),
});
}
await sitesStore.loadSites();
// Restore coverage settings
useCoverageStore.getState().updateSettings(project.coverageSettings);
// Clear any existing coverage result (outdated for loaded project)
useCoverageStore.getState().setResult(null);
set({ currentProjectId: id });
return project;
},
deleteProject: async (id: string) => {
await db.projects.delete(id);
const { currentProjectId } = get();
if (currentProjectId === id) {
set({ currentProjectId: null });
}
await get().loadProjects();
},
}));

View File

@@ -16,6 +16,7 @@ export interface CoverageSettings {
radius: number; // km (calculation radius)
resolution: number; // meters (grid resolution)
rsrpThreshold: number; // dBm (minimum signal to display)
heatmapOpacity: number; // 0.3-1.0 (heatmap layer opacity)
}
export interface GridPoint {