@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 Legend from '@/components/map/Legend.tsx';
|
||||||
import SiteList from '@/components/panels/SiteList.tsx';
|
import SiteList from '@/components/panels/SiteList.tsx';
|
||||||
import SiteForm from '@/components/panels/SiteForm.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 ToastContainer from '@/components/ui/Toast.tsx';
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
import ThemeToggle from '@/components/ui/ThemeToggle.tsx';
|
||||||
import Button from '@/components/ui/Button.tsx';
|
import Button from '@/components/ui/Button.tsx';
|
||||||
@@ -248,6 +250,7 @@ export default function App() {
|
|||||||
<Heatmap
|
<Heatmap
|
||||||
points={coverageResult.points}
|
points={coverageResult.points}
|
||||||
visible={heatmapVisible}
|
visible={heatmapVisible}
|
||||||
|
opacity={settings.heatmapOpacity}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</MapView>
|
</MapView>
|
||||||
@@ -349,9 +352,35 @@ 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">
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Export coverage data */}
|
||||||
|
<ExportPanel />
|
||||||
|
|
||||||
|
{/* Projects save/load */}
|
||||||
|
<ProjectPanel />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ declare module 'leaflet' {
|
|||||||
interface HeatmapProps {
|
interface HeatmapProps {
|
||||||
points: CoveragePoint[];
|
points: CoveragePoint[];
|
||||||
visible: boolean;
|
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 map = useMap();
|
||||||
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
||||||
|
|
||||||
@@ -98,10 +99,16 @@ export default function Heatmap({ points, visible }: HeatmapProps) {
|
|||||||
|
|
||||||
heatLayer.addTo(map);
|
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 () => {
|
return () => {
|
||||||
map.removeLayer(heatLayer);
|
map.removeLayer(heatLayer);
|
||||||
};
|
};
|
||||||
}, [map, points, visible, mapZoom]);
|
}, [map, points, visible, mapZoom, opacity]);
|
||||||
|
|
||||||
return null;
|
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,
|
radius: 10,
|
||||||
resolution: 200,
|
resolution: 200,
|
||||||
rsrpThreshold: -120,
|
rsrpThreshold: -120,
|
||||||
|
heatmapOpacity: 0.7,
|
||||||
},
|
},
|
||||||
heatmapVisible: true,
|
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)
|
radius: number; // km (calculation radius)
|
||||||
resolution: number; // meters (grid resolution)
|
resolution: number; // meters (grid resolution)
|
||||||
rsrpThreshold: number; // dBm (minimum signal to display)
|
rsrpThreshold: number; // dBm (minimum signal to display)
|
||||||
|
heatmapOpacity: number; // 0.3-1.0 (heatmap layer opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GridPoint {
|
export interface GridPoint {
|
||||||
|
|||||||
Reference in New Issue
Block a user