@mytec: initial commit before dt
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
# RFCP Iteration 1.5: Frontend ↔ Backend Integration
|
||||
|
||||
**Date:** January 31, 2025
|
||||
**Type:** Full-Stack Integration
|
||||
**Estimated:** 8-12 hours
|
||||
**Location:** `/opt/rfcp/frontend/` + `/opt/rfcp/backend/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Goal
|
||||
|
||||
Replace browser-based coverage calculation with backend API. Add propagation model selection UI and real-time progress indication.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Current State
|
||||
|
||||
**Frontend:**
|
||||
- Coverage calculated in Web Workers (browser)
|
||||
- Settings stored in localStorage/IndexedDB
|
||||
- No connection to backend API
|
||||
- ~0.15s calculation time (simple model)
|
||||
|
||||
**Backend:**
|
||||
- Full propagation engine ready (1.4)
|
||||
- 4 presets (fast/standard/detailed/full)
|
||||
- 6 propagation models available
|
||||
- API at `https://api.rfcp.eliah.one`
|
||||
|
||||
**Gap:**
|
||||
- Frontend doesn't call backend
|
||||
- No model selection UI
|
||||
- No progress indication for slow calculations
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Frontend │
|
||||
├─────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌─────────────────────────┐ │
|
||||
│ │ Site Panel │───▶│ Coverage Settings Panel │ │
|
||||
│ └─────────────┘ │ ├── Radius │ │
|
||||
│ │ ├── Resolution │ │
|
||||
│ │ ├── Preset [dropdown] │ NEW │
|
||||
│ │ └── Model toggles │ NEW │
|
||||
│ └───────────┬─────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼────────────────┐ │
|
||||
│ │ Coverage Service (API client) │ │
|
||||
│ │ ├── POST /api/coverage/calculate │ │
|
||||
│ │ ├── Progress polling / SSE │ │
|
||||
│ │ └── Result caching │ │
|
||||
│ └──────────────────────────────┬────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────────▼────────────────┐ │
|
||||
│ │ Map Visualization │ │
|
||||
│ │ ├── Heatmap layer (existing) │ │
|
||||
│ │ └── Model info overlay NEW │ │
|
||||
│ └───────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Backend API │
|
||||
│ POST /api/coverage/calculate │
|
||||
│ GET /api/coverage/presets │
|
||||
│ GET /api/terrain/elevation │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Tasks
|
||||
|
||||
### Task 1.5.1: API Client Service (2-3 hours)
|
||||
|
||||
**frontend/src/services/api.ts:**
|
||||
```typescript
|
||||
const API_BASE = import.meta.env.VITE_API_URL || 'https://api.rfcp.eliah.one';
|
||||
|
||||
export interface CoverageRequest {
|
||||
sites: SiteParams[];
|
||||
settings: CoverageSettings;
|
||||
}
|
||||
|
||||
export interface SiteParams {
|
||||
lat: number;
|
||||
lon: number;
|
||||
height: number;
|
||||
power: number;
|
||||
gain: number;
|
||||
frequency: number;
|
||||
azimuth?: number;
|
||||
beamwidth?: number;
|
||||
}
|
||||
|
||||
export interface CoverageSettings {
|
||||
radius: number;
|
||||
resolution: number;
|
||||
min_signal: number;
|
||||
preset?: 'fast' | 'standard' | 'detailed' | 'full';
|
||||
use_terrain?: boolean;
|
||||
use_buildings?: boolean;
|
||||
use_materials?: boolean;
|
||||
use_dominant_path?: boolean;
|
||||
use_street_canyon?: boolean;
|
||||
use_reflections?: boolean;
|
||||
}
|
||||
|
||||
export interface CoveragePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
rsrp: number;
|
||||
distance: number;
|
||||
has_los: boolean;
|
||||
terrain_loss: number;
|
||||
building_loss: number;
|
||||
reflection_gain: number;
|
||||
}
|
||||
|
||||
export interface CoverageResponse {
|
||||
points: CoveragePoint[];
|
||||
count: number;
|
||||
settings: CoverageSettings;
|
||||
stats: CoverageStats;
|
||||
computation_time: number;
|
||||
models_used: string[];
|
||||
}
|
||||
|
||||
export interface CoverageStats {
|
||||
min_rsrp: number;
|
||||
max_rsrp: number;
|
||||
avg_rsrp: number;
|
||||
los_percentage: number;
|
||||
points_with_buildings: number;
|
||||
points_with_terrain_loss: number;
|
||||
points_with_reflection_gain: number;
|
||||
}
|
||||
|
||||
export interface Preset {
|
||||
description: string;
|
||||
use_terrain: boolean;
|
||||
use_buildings: boolean;
|
||||
use_materials: boolean;
|
||||
use_dominant_path: boolean;
|
||||
use_street_canyon: boolean;
|
||||
use_reflections: boolean;
|
||||
estimated_speed: string;
|
||||
}
|
||||
|
||||
class ApiService {
|
||||
private abortController: AbortController | null = null;
|
||||
|
||||
async getPresets(): Promise<Record<string, Preset>> {
|
||||
const response = await fetch(`${API_BASE}/api/coverage/presets`);
|
||||
if (!response.ok) throw new Error('Failed to fetch presets');
|
||||
const data = await response.json();
|
||||
return data.presets;
|
||||
}
|
||||
|
||||
async calculateCoverage(
|
||||
request: CoverageRequest,
|
||||
onProgress?: (progress: number) => void
|
||||
): Promise<CoverageResponse> {
|
||||
// Cancel previous request if running
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
this.abortController = new AbortController();
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/coverage/calculate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'Coverage calculation failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
cancelCalculation() {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort();
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
|
||||
async getElevation(lat: number, lon: number): Promise<number> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/terrain/elevation?lat=${lat}&lon=${lon}`
|
||||
);
|
||||
if (!response.ok) return 0;
|
||||
const data = await response.json();
|
||||
return data.elevation;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiService();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.2: Coverage Settings Panel Update (3-4 hours)
|
||||
|
||||
**frontend/src/components/panels/CoverageSettingsPanel.tsx:**
|
||||
```typescript
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api, Preset } from '../../services/api';
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
import { NumberInput } from '../ui/NumberInput';
|
||||
import { Toggle } from '../ui/Toggle';
|
||||
|
||||
export function CoverageSettingsPanel() {
|
||||
const { settings, updateSettings, isCalculating } = useCoverageStore();
|
||||
const [presets, setPresets] = useState<Record<string, Preset>>({});
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
// Load presets on mount
|
||||
useEffect(() => {
|
||||
api.getPresets().then(setPresets).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const handlePresetChange = (preset: string) => {
|
||||
if (presets[preset]) {
|
||||
updateSettings({
|
||||
preset,
|
||||
...presets[preset]
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="coverage-settings-panel">
|
||||
<h3>Coverage Settings</h3>
|
||||
|
||||
{/* Basic Settings */}
|
||||
<NumberInput
|
||||
label="Radius"
|
||||
value={settings.radius / 1000}
|
||||
onChange={(v) => updateSettings({ radius: v * 1000 })}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
unit="km"
|
||||
tooltip="Coverage calculation radius around each site"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Resolution"
|
||||
value={settings.resolution}
|
||||
onChange={(v) => updateSettings({ resolution: v })}
|
||||
min={50}
|
||||
max={500}
|
||||
step={50}
|
||||
unit="m"
|
||||
tooltip="Grid spacing — lower = more accurate but slower"
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Min Signal"
|
||||
value={settings.min_signal}
|
||||
onChange={(v) => updateSettings({ min_signal: v })}
|
||||
min={-140}
|
||||
max={-50}
|
||||
step={5}
|
||||
unit="dBm"
|
||||
tooltip="RSRP threshold — points below this are hidden"
|
||||
/>
|
||||
|
||||
{/* Propagation Model Preset */}
|
||||
<div className="setting-group">
|
||||
<label>Propagation Model</label>
|
||||
<select
|
||||
value={settings.preset || 'standard'}
|
||||
onChange={(e) => handlePresetChange(e.target.value)}
|
||||
disabled={isCalculating}
|
||||
>
|
||||
{Object.entries(presets).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} — {preset.estimated_speed}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{presets[settings.preset || 'standard'] && (
|
||||
<p className="hint">
|
||||
{presets[settings.preset || 'standard'].description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Toggles */}
|
||||
<div className="advanced-section">
|
||||
<button
|
||||
className="advanced-toggle"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
>
|
||||
{showAdvanced ? '▼' : '▶'} Advanced Options
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="advanced-options">
|
||||
<Toggle
|
||||
label="Terrain (SRTM)"
|
||||
checked={settings.use_terrain}
|
||||
onChange={(v) => updateSettings({ use_terrain: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Buildings (OSM)"
|
||||
checked={settings.use_buildings}
|
||||
onChange={(v) => updateSettings({ use_buildings: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Building Materials"
|
||||
checked={settings.use_materials}
|
||||
onChange={(v) => updateSettings({ use_materials: v })}
|
||||
disabled={isCalculating || !settings.use_buildings}
|
||||
/>
|
||||
<Toggle
|
||||
label="Dominant Path Analysis"
|
||||
checked={settings.use_dominant_path}
|
||||
onChange={(v) => updateSettings({ use_dominant_path: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Street Canyon"
|
||||
checked={settings.use_street_canyon}
|
||||
onChange={(v) => updateSettings({ use_street_canyon: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
<Toggle
|
||||
label="Reflections"
|
||||
checked={settings.use_reflections}
|
||||
onChange={(v) => updateSettings({ use_reflections: v })}
|
||||
disabled={isCalculating}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.3: Coverage Store Update (2-3 hours)
|
||||
|
||||
**frontend/src/store/coverage.ts:**
|
||||
```typescript
|
||||
import { create } from 'zustand';
|
||||
import { api, CoverageResponse, CoverageSettings, CoveragePoint } from '../services/api';
|
||||
import { useSitesStore } from './sites';
|
||||
|
||||
interface CoverageState {
|
||||
// Data
|
||||
points: CoveragePoint[];
|
||||
stats: CoverageResponse['stats'] | null;
|
||||
|
||||
// Settings
|
||||
settings: CoverageSettings;
|
||||
|
||||
// UI State
|
||||
isCalculating: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
lastCalculation: {
|
||||
time: number;
|
||||
models: string[];
|
||||
} | null;
|
||||
|
||||
// Actions
|
||||
updateSettings: (settings: Partial<CoverageSettings>) => void;
|
||||
calculateCoverage: () => Promise<void>;
|
||||
cancelCalculation: () => void;
|
||||
clearCoverage: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: CoverageSettings = {
|
||||
radius: 10000,
|
||||
resolution: 200,
|
||||
min_signal: -100,
|
||||
preset: 'standard',
|
||||
use_terrain: true,
|
||||
use_buildings: true,
|
||||
use_materials: true,
|
||||
use_dominant_path: false,
|
||||
use_street_canyon: false,
|
||||
use_reflections: false,
|
||||
};
|
||||
|
||||
export const useCoverageStore = create<CoverageState>((set, get) => ({
|
||||
points: [],
|
||||
stats: null,
|
||||
settings: DEFAULT_SETTINGS,
|
||||
isCalculating: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
lastCalculation: null,
|
||||
|
||||
updateSettings: (newSettings) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...newSettings }
|
||||
}));
|
||||
},
|
||||
|
||||
calculateCoverage: async () => {
|
||||
const { settings } = get();
|
||||
const sites = useSitesStore.getState().sites;
|
||||
|
||||
if (sites.length === 0) {
|
||||
set({ error: 'No sites to calculate coverage for' });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isCalculating: true, progress: 0, error: null });
|
||||
|
||||
try {
|
||||
// Convert sites to API format
|
||||
const apiSites = sites.flatMap(site =>
|
||||
site.sectors.map(sector => ({
|
||||
lat: site.lat,
|
||||
lon: site.lon,
|
||||
height: sector.height,
|
||||
power: 10 * Math.log10(sector.power * 1000), // W to dBm
|
||||
gain: sector.gain,
|
||||
frequency: sector.frequency,
|
||||
azimuth: sector.antennaType === 'directional' ? sector.azimuth : undefined,
|
||||
beamwidth: sector.antennaType === 'directional' ? sector.beamwidth : undefined,
|
||||
}))
|
||||
);
|
||||
|
||||
const response = await api.calculateCoverage({
|
||||
sites: apiSites,
|
||||
settings
|
||||
});
|
||||
|
||||
set({
|
||||
points: response.points,
|
||||
stats: response.stats,
|
||||
isCalculating: false,
|
||||
progress: 100,
|
||||
lastCalculation: {
|
||||
time: response.computation_time,
|
||||
models: response.models_used
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
set({ isCalculating: false, progress: 0 });
|
||||
} else {
|
||||
set({
|
||||
isCalculating: false,
|
||||
error: error instanceof Error ? error.message : 'Calculation failed',
|
||||
progress: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
cancelCalculation: () => {
|
||||
api.cancelCalculation();
|
||||
set({ isCalculating: false, progress: 0 });
|
||||
},
|
||||
|
||||
clearCoverage: () => {
|
||||
set({ points: [], stats: null, lastCalculation: null });
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.4: Calculate Button & Progress (2-3 hours)
|
||||
|
||||
**frontend/src/components/CoverageButton.tsx:**
|
||||
```typescript
|
||||
import { useCoverageStore } from '../store/coverage';
|
||||
import { useSitesStore } from '../store/sites';
|
||||
|
||||
export function CoverageButton() {
|
||||
const {
|
||||
isCalculating,
|
||||
progress,
|
||||
calculateCoverage,
|
||||
cancelCalculation,
|
||||
lastCalculation
|
||||
} = useCoverageStore();
|
||||
const sitesCount = useSitesStore((s) => s.sites.length);
|
||||
|
||||
if (isCalculating) {
|
||||
return (
|
||||
<div className="coverage-button calculating">
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={cancelCalculation} className="cancel-btn">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="coverage-button">
|
||||
<button
|
||||
onClick={calculateCoverage}
|
||||
disabled={sitesCount === 0}
|
||||
className="calculate-btn"
|
||||
>
|
||||
Calculate Coverage
|
||||
</button>
|
||||
{lastCalculation && (
|
||||
<span className="last-calc-info">
|
||||
{lastCalculation.time.toFixed(1)}s • {lastCalculation.models.length} models
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**CSS (coverage-button.css):**
|
||||
```css
|
||||
.coverage-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calculate-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.calculate-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.calculate-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.coverage-button.calculating {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 200px;
|
||||
height: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.last-calc-info {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.5: Update Heatmap to Use API Points (2-3 hours)
|
||||
|
||||
**frontend/src/components/map/CoverageLayer.tsx:**
|
||||
```typescript
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMap } from 'react-leaflet';
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
import { createHeatmapTiles } from '../../lib/heatmap';
|
||||
|
||||
export function CoverageLayer() {
|
||||
const map = useMap();
|
||||
const { points, settings } = useCoverageStore();
|
||||
|
||||
// Convert API points to heatmap format
|
||||
const heatmapData = useMemo(() => {
|
||||
if (!points.length) return [];
|
||||
|
||||
return points.map(p => ({
|
||||
lat: p.lat,
|
||||
lon: p.lon,
|
||||
value: p.rsrp,
|
||||
// Additional data for tooltips
|
||||
hasLos: p.has_los,
|
||||
terrainLoss: p.terrain_loss,
|
||||
buildingLoss: p.building_loss,
|
||||
reflectionGain: p.reflection_gain,
|
||||
}));
|
||||
}, [points]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!heatmapData.length) return;
|
||||
|
||||
const tileLayer = createHeatmapTiles(heatmapData, {
|
||||
minSignal: settings.min_signal,
|
||||
maxSignal: -50,
|
||||
opacity: 0.7,
|
||||
});
|
||||
|
||||
tileLayer.addTo(map);
|
||||
|
||||
return () => {
|
||||
map.removeLayer(tileLayer);
|
||||
};
|
||||
}, [map, heatmapData, settings.min_signal]);
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.6: Stats Panel Update (1-2 hours)
|
||||
|
||||
**frontend/src/components/panels/StatsPanel.tsx:**
|
||||
```typescript
|
||||
import { useCoverageStore } from '../../store/coverage';
|
||||
|
||||
export function StatsPanel() {
|
||||
const { stats, lastCalculation, points } = useCoverageStore();
|
||||
|
||||
if (!stats) {
|
||||
return (
|
||||
<div className="stats-panel empty">
|
||||
<p>Calculate coverage to see statistics</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stats-panel">
|
||||
<h3>Coverage Statistics</h3>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat">
|
||||
<span className="label">Points</span>
|
||||
<span className="value">{points.length.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Min RSRP</span>
|
||||
<span className="value">{stats.min_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Max RSRP</span>
|
||||
<span className="value">{stats.max_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Avg RSRP</span>
|
||||
<span className="value">{stats.avg_rsrp.toFixed(1)} dBm</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Line of Sight</span>
|
||||
<span className="value">{stats.los_percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Terrain Affected</span>
|
||||
<span className="value">{stats.points_with_terrain_loss}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">Building Affected</span>
|
||||
<span className="value">{stats.points_with_buildings}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat">
|
||||
<span className="label">With Reflections</span>
|
||||
<span className="value">{stats.points_with_reflection_gain}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lastCalculation && (
|
||||
<div className="calc-info">
|
||||
<p>
|
||||
Calculated in <strong>{lastCalculation.time.toFixed(1)}s</strong>
|
||||
</p>
|
||||
<p className="models">
|
||||
Models: {lastCalculation.models.join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1.5.7: Environment Configuration (1 hour)
|
||||
|
||||
**frontend/.env.development:**
|
||||
```env
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
```
|
||||
|
||||
**frontend/.env.production:**
|
||||
```env
|
||||
VITE_API_URL=https://api.rfcp.eliah.one
|
||||
```
|
||||
|
||||
**frontend/.env.local (for local backend):**
|
||||
```env
|
||||
VITE_API_URL=http://localhost:8888
|
||||
```
|
||||
|
||||
**frontend/vite.config.ts update:**
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
// ... existing config
|
||||
define: {
|
||||
'import.meta.env.VITE_API_URL': JSON.stringify(process.env.VITE_API_URL)
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
```bash
|
||||
# 1. Start backend (if not running)
|
||||
sudo systemctl start rfcp-backend
|
||||
|
||||
# 2. Start frontend dev
|
||||
cd /opt/rfcp/frontend
|
||||
npm run dev
|
||||
|
||||
# 3. Test in browser
|
||||
# - Create a site
|
||||
# - Select "Fast" preset
|
||||
# - Click Calculate Coverage
|
||||
# - Verify heatmap appears
|
||||
# - Check stats panel shows API data
|
||||
|
||||
# 4. Test presets
|
||||
# - Switch to "Standard" - should take ~30s
|
||||
# - Switch to "Full" (small radius!) - should take ~2min
|
||||
# - Verify models_used changes
|
||||
|
||||
# 5. Test cancel
|
||||
# - Start "Full" calculation
|
||||
# - Click Cancel
|
||||
# - Verify it stops
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Success Criteria
|
||||
|
||||
- [ ] Coverage calculated via backend API (not browser)
|
||||
- [ ] Preset dropdown shows 4 options with descriptions
|
||||
- [ ] Advanced toggles work independently
|
||||
- [ ] Progress indication during calculation
|
||||
- [ ] Cancel button stops calculation
|
||||
- [ ] Stats panel shows API response data
|
||||
- [ ] `computation_time` and `models_used` displayed
|
||||
- [ ] Heatmap renders API points correctly
|
||||
- [ ] Error handling for API failures
|
||||
- [ ] Works with multiple sites/sectors
|
||||
|
||||
---
|
||||
|
||||
## 📁 Files Changed
|
||||
|
||||
```
|
||||
frontend/src/
|
||||
├── services/
|
||||
│ └── api.ts # NEW - API client
|
||||
├── store/
|
||||
│ └── coverage.ts # MODIFIED - API integration
|
||||
├── components/
|
||||
│ ├── panels/
|
||||
│ │ ├── CoverageSettingsPanel.tsx # MODIFIED - preset UI
|
||||
│ │ └── StatsPanel.tsx # MODIFIED - API stats
|
||||
│ ├── map/
|
||||
│ │ └── CoverageLayer.tsx # MODIFIED - use API points
|
||||
│ └── CoverageButton.tsx # NEW - calculate + progress
|
||||
├── .env.development # NEW
|
||||
├── .env.production # NEW
|
||||
└── vite.config.ts # MODIFIED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- Remove or disable old Web Worker calculation after integration confirmed
|
||||
- Keep localStorage for sites/settings backup (offline fallback)
|
||||
- Consider WebSocket for real progress updates (future enhancement)
|
||||
- API timeout should be generous (5+ minutes for "full" preset)
|
||||
|
||||
---
|
||||
|
||||
## 🔜 Next
|
||||
|
||||
After 1.5 complete:
|
||||
- **1.4.1** — Enhanced Environment (R-tree, water, vegetation)
|
||||
- **1.4.2** — Extra Factors (weather, indoor)
|
||||
- **2.1** — Desktop Installer
|
||||
|
||||
---
|
||||
|
||||
**Ready for Claude Code** 🚀
|
||||
Reference in New Issue
Block a user