280 lines
8.2 KiB
TypeScript
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 });
|
|
},
|
|
}));
|