Files
rfcp/frontend/src/store/sites.ts
2026-01-30 14:27:38 +02:00

280 lines
8.2 KiB
TypeScript

import { create } from 'zustand';
import { v4 as uuidv4 } from 'uuid';
import type { Site, SiteFormData } from '@/types/index.ts';
import { db } from '@/db/schema.ts';
import { useCoverageStore } from '@/store/coverage.ts';
const SITE_COLORS = [
'#ef4444', '#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16',
];
interface SitesState {
sites: Site[];
selectedSiteId: string | null;
editingSiteId: string | null;
isPlacingMode: boolean;
selectedSiteIds: string[];
loadSites: () => Promise<void>;
addSite: (data: SiteFormData) => Promise<Site>;
updateSite: (id: string, data: Partial<Site>) => Promise<void>;
deleteSite: (id: string) => Promise<void>;
selectSite: (id: string | null) => void;
setEditingSite: (id: string | null) => void;
togglePlacingMode: () => void;
setPlacingMode: (val: boolean) => void;
// Multi-sector
cloneSiteAsSectors: (siteId: string, sectorCount: 2 | 3) => Promise<void>;
cloneSector: (siteId: string) => Promise<void>;
// Import/export
importSites: (sitesData: SiteFormData[]) => Promise<number>;
// Batch operations
toggleSiteSelection: (siteId: string) => void;
selectAllSites: () => void;
clearSelection: () => void;
batchUpdateHeight: (adjustment: number) => Promise<void>;
batchSetHeight: (height: number) => Promise<void>;
}
export const useSitesStore = create<SitesState>((set, get) => ({
sites: [],
selectedSiteId: null,
editingSiteId: null,
isPlacingMode: false,
selectedSiteIds: [],
loadSites: async () => {
const dbSites = await db.sites.toArray();
const sites: Site[] = dbSites.map((s) => ({
...JSON.parse(s.data),
createdAt: new Date(s.createdAt),
updatedAt: new Date(s.updatedAt),
}));
set({ sites });
},
addSite: async (data: SiteFormData) => {
const id = uuidv4();
const now = new Date();
const colorIndex = get().sites.length % SITE_COLORS.length;
const site: Site = {
...data,
id,
color: data.color || SITE_COLORS[colorIndex],
visible: true,
createdAt: now,
updatedAt: now,
};
await db.sites.put({
id,
data: JSON.stringify(site),
createdAt: now.getTime(),
updatedAt: now.getTime(),
});
set((state) => ({ sites: [...state.sites, site] }));
return site;
},
updateSite: async (id: string, data: Partial<Site>) => {
const sites = get().sites;
const existing = sites.find((s) => s.id === id);
if (!existing) return;
const updated: Site = {
...existing,
...data,
updatedAt: new Date(),
};
await db.sites.put({
id,
data: JSON.stringify(updated),
createdAt: existing.createdAt.getTime(),
updatedAt: updated.updatedAt.getTime(),
});
set((state) => ({
sites: state.sites.map((s) => (s.id === id ? updated : s)),
}));
},
deleteSite: async (id: string) => {
await db.sites.delete(id);
set((state) => ({
sites: state.sites.filter((s) => s.id !== id),
selectedSiteId: state.selectedSiteId === id ? null : state.selectedSiteId,
editingSiteId: state.editingSiteId === id ? null : state.editingSiteId,
}));
// Clear stale coverage data
useCoverageStore.getState().clearCoverage();
},
selectSite: (id: string | null) => set({ selectedSiteId: id }),
setEditingSite: (id: string | null) => set({ editingSiteId: id }),
togglePlacingMode: () => set((s) => ({ isPlacingMode: !s.isPlacingMode })),
setPlacingMode: (val: boolean) => set({ isPlacingMode: val }),
// Multi-sector: clone a site into 2 or 3 co-located sector sites
cloneSiteAsSectors: async (siteId: string, sectorCount: 2 | 3) => {
const source = get().sites.find((s) => s.id === siteId);
if (!source) return;
const spacing = 360 / sectorCount;
const addSite = get().addSite;
for (let i = 0; i < sectorCount; i++) {
const azimuth = Math.round(i * spacing) % 360;
const label = sectorCount === 2
? ['Alpha', 'Beta'][i]
: ['Alpha', 'Beta', 'Gamma'][i];
await addSite({
name: `${source.name}-${label}`,
lat: source.lat,
lon: source.lon,
height: source.height,
power: source.power,
gain: source.gain >= 15 ? source.gain : 18, // sector gain default
frequency: source.frequency,
antennaType: 'sector',
azimuth,
beamwidth: sectorCount === 2 ? 90 : 65,
color: '',
visible: true,
notes: `Sector ${i + 1} (${label}) of ${source.name}`,
});
}
},
// Clone a single sector: duplicate site at same location with 120° azimuth offset (tri-sector spacing)
cloneSector: async (siteId: string) => {
const source = get().sites.find((s) => s.id === siteId);
if (!source) return;
// Count existing sectors at this location to determine naming
const colocated = get().sites.filter(
(s) => s.lat === source.lat && s.lon === source.lon
);
const sectorLabels = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta'];
const sectorIndex = colocated.length; // 0-indexed, next sector
const label = sectorLabels[sectorIndex] ?? `S${sectorIndex + 1}`;
// Base name without any existing sector suffix
const baseName = source.name.replace(/-(Alpha|Beta|Gamma|Delta|Epsilon|Zeta|S\d+|clone)$/i, '');
const addSite = get().addSite;
const newAzimuth = ((source.azimuth ?? 0) + 120) % 360; // 120° for tri-sector
await addSite({
name: `${baseName}-${label}`,
lat: source.lat,
lon: source.lon,
height: source.height,
power: source.power,
gain: source.gain >= 15 ? source.gain : 18, // sector gain default
frequency: source.frequency,
antennaType: 'sector',
azimuth: newAzimuth,
beamwidth: source.beamwidth ?? 65,
color: '',
visible: true,
notes: `Sector ${label} at ${baseName}`,
});
// Clear coverage to force recalculation
useCoverageStore.getState().clearCoverage();
},
// Import sites from parsed data
importSites: async (sitesData: SiteFormData[]) => {
const addSite = get().addSite;
let count = 0;
for (const data of sitesData) {
await addSite(data);
count++;
}
return count;
},
// Batch operations
toggleSiteSelection: (siteId: string) => {
set((state) => {
const isSelected = state.selectedSiteIds.includes(siteId);
return {
selectedSiteIds: isSelected
? state.selectedSiteIds.filter((id) => id !== siteId)
: [...state.selectedSiteIds, siteId],
};
});
},
selectAllSites: () => {
set((state) => ({
selectedSiteIds: state.sites.map((s) => s.id),
}));
},
clearSelection: () => {
set({ selectedSiteIds: [] });
},
batchUpdateHeight: async (adjustment: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
return {
...site,
height: Math.max(1, Math.min(100, site.height + adjustment)),
updatedAt: now,
};
});
// Persist to IndexedDB
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
},
batchSetHeight: async (height: number) => {
const { sites, selectedSiteIds } = get();
const selectedSet = new Set(selectedSiteIds);
const clampedHeight = Math.max(1, Math.min(100, height));
const now = new Date();
const updatedSites = sites.map((site) => {
if (!selectedSet.has(site.id)) return site;
return {
...site,
height: clampedHeight,
updatedAt: now,
};
});
// Persist to IndexedDB
const toUpdate = updatedSites.filter((s) => selectedSet.has(s.id));
for (const site of toUpdate) {
await db.sites.put({
id: site.id,
data: JSON.stringify(site),
createdAt: site.createdAt.getTime(),
updatedAt: now.getTime(),
});
}
set({ sites: updatedSites });
},
}));