@mytec: iteration3 done for testing
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
92
frontend/src/components/panels/ExportPanel.tsx
Normal file
92
frontend/src/components/panels/ExportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
frontend/src/components/panels/ProjectPanel.tsx
Normal file
165
frontend/src/components/panels/ProjectPanel.tsx
Normal 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' : ''} ·{' '}
|
||||
{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"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
frontend/src/services/terrain.ts
Normal file
89
frontend/src/services/terrain.ts
Normal 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();
|
||||
@@ -21,6 +21,7 @@ export const useCoverageStore = create<CoverageState>((set) => ({
|
||||
radius: 10,
|
||||
resolution: 200,
|
||||
rsrpThreshold: -120,
|
||||
heatmapOpacity: 0.7,
|
||||
},
|
||||
heatmapVisible: true,
|
||||
|
||||
|
||||
112
frontend/src/store/projects.ts
Normal file
112
frontend/src/store/projects.ts
Normal 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();
|
||||
},
|
||||
}));
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user