430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { Polyline, Marker, Popup, Circle, useMapEvents } from 'react-leaflet';
|
|
import L from 'leaflet';
|
|
import { mapItemService } from '../../services/mapItemService';
|
|
import { CABLE_COLORS, type MapItem, type CableType } from '../../types/mapItem';
|
|
import { ItemContextMenu } from './ItemContextMenu';
|
|
import { useDrawingStore } from '../../stores/drawingStore';
|
|
|
|
interface MapItemsLayerProps {
|
|
mapId: string;
|
|
refreshTrigger: number;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
// Custom marker icons for devices using CSS
|
|
const switchIcon = new L.DivIcon({
|
|
html: `<div class="device-icon switch-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<rect x="2" y="4" width="20" height="16" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
|
<circle cx="6" cy="10" r="1.5"/>
|
|
<circle cx="10" cy="10" r="1.5"/>
|
|
<circle cx="14" cy="10" r="1.5"/>
|
|
<circle cx="18" cy="10" r="1.5"/>
|
|
<circle cx="6" cy="14" r="1.5"/>
|
|
<circle cx="10" cy="14" r="1.5"/>
|
|
<circle cx="14" cy="14" r="1.5"/>
|
|
<circle cx="18" cy="14" r="1.5"/>
|
|
</svg>
|
|
</div>`,
|
|
className: 'custom-device-marker',
|
|
iconSize: [40, 40],
|
|
iconAnchor: [20, 40],
|
|
});
|
|
|
|
const indoorApIcon = new L.DivIcon({
|
|
html: `<div class="device-icon ap-indoor-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z" opacity="0.3"/>
|
|
<circle cx="12" cy="12" r="3"/>
|
|
<path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4z"/>
|
|
</svg>
|
|
</div>`,
|
|
className: 'custom-device-marker',
|
|
iconSize: [40, 40],
|
|
iconAnchor: [20, 40],
|
|
});
|
|
|
|
const outdoorApIcon = new L.DivIcon({
|
|
html: `<div class="device-icon ap-outdoor-icon">
|
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9z"/>
|
|
<path d="M9 17l3 3 3-3c-1.65-1.66-4.34-1.66-6 0z"/>
|
|
<path d="M5 13l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z"/>
|
|
</svg>
|
|
</div>`,
|
|
className: 'custom-device-marker',
|
|
iconSize: [40, 40],
|
|
iconAnchor: [20, 40],
|
|
});
|
|
|
|
export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapItemsLayerProps) {
|
|
const [items, setItems] = useState<MapItem[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [contextMenu, setContextMenu] = useState<{
|
|
item: MapItem;
|
|
position: { x: number; y: number };
|
|
} | null>(null);
|
|
const { activeTool } = useDrawingStore();
|
|
|
|
// Check if we're in drawing mode (should suppress popups)
|
|
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
|
|
const isWirelessTool = activeTool === 'wireless_mesh';
|
|
const shouldSuppressPopups = isCableTool || isWirelessTool;
|
|
|
|
useEffect(() => {
|
|
loadItems();
|
|
}, [mapId, refreshTrigger]);
|
|
|
|
const loadItems = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const data = await mapItemService.getMapItems(mapId);
|
|
console.log('Loaded items:', data);
|
|
// Log devices with their connections
|
|
data.forEach(item => {
|
|
if (['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type)) {
|
|
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
|
|
}
|
|
});
|
|
setItems(data);
|
|
} catch (error) {
|
|
console.error('Failed to load map items:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Close context menu on any map click
|
|
useMapEvents({
|
|
click: () => setContextMenu(null),
|
|
contextmenu: () => {}, // Prevent default map context menu
|
|
});
|
|
|
|
const getDeviceIcon = (type: string) => {
|
|
switch (type) {
|
|
case 'switch':
|
|
return switchIcon;
|
|
case 'indoor_ap':
|
|
return indoorApIcon;
|
|
case 'outdoor_ap':
|
|
return outdoorApIcon;
|
|
default:
|
|
return undefined;
|
|
}
|
|
};
|
|
|
|
if (loading) return null;
|
|
|
|
return (
|
|
<>
|
|
{items.map((item) => {
|
|
// Render cables
|
|
if (item.type === 'cable' && item.geometry.type === 'LineString') {
|
|
const positions = item.geometry.coordinates.map(
|
|
([lng, lat]) => [lat, lng] as [number, number]
|
|
);
|
|
const cableType = item.properties.cable_type as CableType;
|
|
const color = CABLE_COLORS[cableType] || '#6B7280';
|
|
|
|
// Find connected devices
|
|
const startDevice = item.properties.start_device_id
|
|
? items.find(i => i.id === item.properties.start_device_id)
|
|
: null;
|
|
const endDevice = item.properties.end_device_id
|
|
? items.find(i => i.id === item.properties.end_device_id)
|
|
: null;
|
|
|
|
return (
|
|
<div key={item.id}>
|
|
<Polyline
|
|
positions={positions}
|
|
color={color}
|
|
weight={3}
|
|
opacity={0.8}
|
|
eventHandlers={{
|
|
contextmenu: (e) => {
|
|
L.DomEvent.stopPropagation(e);
|
|
if (!readOnly) {
|
|
setContextMenu({
|
|
item,
|
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
|
});
|
|
}
|
|
},
|
|
}}
|
|
>
|
|
{!shouldSuppressPopups && (
|
|
<Popup>
|
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Cable'}</div>
|
|
<div className="text-gray-600 dark:text-gray-400">Type: {cableType}</div>
|
|
{startDevice && (
|
|
<div className="text-gray-600 dark:text-gray-400 mt-1">
|
|
From: {startDevice.properties.name || startDevice.type}
|
|
</div>
|
|
)}
|
|
{endDevice && (
|
|
<div className="text-gray-600 dark:text-gray-400">
|
|
To: {endDevice.properties.name || endDevice.type}
|
|
</div>
|
|
)}
|
|
{item.properties.notes && (
|
|
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
{item.properties.notes}
|
|
</div>
|
|
)}
|
|
{item.properties.image && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={item.properties.image}
|
|
alt="Attachment"
|
|
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</Polyline>
|
|
|
|
{/* Show circles at cable bend points (not first/last) */}
|
|
{positions.slice(1, -1).map((pos, idx) => (
|
|
<Circle
|
|
key={`${item.id}-bend-${idx}`}
|
|
center={pos}
|
|
radius={1}
|
|
pathOptions={{
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.3,
|
|
weight: 2,
|
|
}}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render wireless mesh
|
|
if (item.type === 'wireless_mesh' && item.geometry.type === 'LineString') {
|
|
const positions = item.geometry.coordinates.map(
|
|
([lng, lat]) => [lat, lng] as [number, number]
|
|
);
|
|
|
|
// Find connected APs
|
|
const startAp = item.properties.start_ap_id
|
|
? items.find(i => i.id === item.properties.start_ap_id)
|
|
: null;
|
|
const endAp = item.properties.end_ap_id
|
|
? items.find(i => i.id === item.properties.end_ap_id)
|
|
: null;
|
|
|
|
return (
|
|
<Polyline
|
|
key={item.id}
|
|
positions={positions}
|
|
color="#10B981"
|
|
weight={3}
|
|
opacity={0.8}
|
|
dashArray="10, 10"
|
|
eventHandlers={{
|
|
contextmenu: (e) => {
|
|
L.DomEvent.stopPropagation(e);
|
|
if (!readOnly) {
|
|
setContextMenu({
|
|
item,
|
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
|
});
|
|
}
|
|
},
|
|
}}
|
|
>
|
|
{!shouldSuppressPopups && (
|
|
<Popup>
|
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || 'Wireless Mesh'}</div>
|
|
{startAp && (
|
|
<div className="text-gray-600 dark:text-gray-400 mt-1">
|
|
From: {startAp.properties.name || startAp.type}
|
|
</div>
|
|
)}
|
|
{endAp && (
|
|
<div className="text-gray-600 dark:text-gray-400">
|
|
To: {endAp.properties.name || endAp.type}
|
|
</div>
|
|
)}
|
|
{item.properties.notes && (
|
|
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
{item.properties.notes}
|
|
</div>
|
|
)}
|
|
{item.properties.image && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={item.properties.image}
|
|
alt="Attachment"
|
|
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</Polyline>
|
|
);
|
|
}
|
|
|
|
// Render devices
|
|
if (
|
|
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
|
|
item.geometry.type === 'Point'
|
|
) {
|
|
const [lng, lat] = item.geometry.coordinates;
|
|
const position: [number, number] = [lat, lng];
|
|
const icon = getDeviceIcon(item.type);
|
|
|
|
return (
|
|
<Marker
|
|
key={item.id}
|
|
position={position}
|
|
icon={icon}
|
|
eventHandlers={{
|
|
contextmenu: (e) => {
|
|
L.DomEvent.stopPropagation(e);
|
|
if (!readOnly) {
|
|
setContextMenu({
|
|
item,
|
|
position: { x: e.originalEvent.clientX, y: e.originalEvent.clientY }
|
|
});
|
|
}
|
|
},
|
|
}}
|
|
>
|
|
{!shouldSuppressPopups && (
|
|
<Popup>
|
|
<div className="text-sm dark:bg-gray-800 dark:text-white" style={{ minWidth: '200px' }}>
|
|
<div className="font-semibold text-gray-900 dark:text-white">{item.properties.name || item.type}</div>
|
|
<div className="text-gray-600 dark:text-gray-400">Type: {item.type}</div>
|
|
{item.properties.port_count && (
|
|
<div className="text-gray-600 dark:text-gray-400">
|
|
Ports: {item.properties.connections?.length || 0} / {item.properties.port_count}
|
|
</div>
|
|
)}
|
|
|
|
{/* Show port connections details */}
|
|
{item.properties.connections && item.properties.connections.length > 0 && (
|
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Port Connections:</div>
|
|
{item.properties.connections
|
|
.sort((a: any, b: any) => a.port_number - b.port_number)
|
|
.map((conn: any) => {
|
|
const cable = items.find(i => i.id === conn.cable_id);
|
|
if (!cable) return null;
|
|
|
|
// Find the other device connected to this cable
|
|
const otherDeviceId = cable.properties.start_device_id === item.id
|
|
? cable.properties.end_device_id
|
|
: cable.properties.start_device_id;
|
|
|
|
const otherDevice = otherDeviceId
|
|
? items.find(i => i.id === otherDeviceId)
|
|
: null;
|
|
|
|
const cableType = cable.properties.cable_type;
|
|
|
|
return (
|
|
<div key={conn.cable_id} className="text-xs text-gray-600 dark:text-gray-400 ml-2">
|
|
Port {conn.port_number} → {otherDevice
|
|
? `${otherDevice.properties.name || otherDevice.type} (${cableType})`
|
|
: `${cableType} cable`}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Show wireless mesh count for APs */}
|
|
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (
|
|
<div className="text-gray-600 dark:text-gray-400">
|
|
Wireless mesh: {items.filter(i =>
|
|
i.type === 'wireless_mesh' &&
|
|
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
|
|
).length} / 4
|
|
</div>
|
|
)}
|
|
|
|
{/* Show wireless mesh connections details for APs */}
|
|
{['indoor_ap', 'outdoor_ap'].includes(item.type) && (() => {
|
|
const meshLinks = items.filter(i =>
|
|
i.type === 'wireless_mesh' &&
|
|
(i.properties.start_ap_id === item.id || i.properties.end_ap_id === item.id)
|
|
);
|
|
|
|
if (meshLinks.length > 0) {
|
|
return (
|
|
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
<div className="font-semibold text-gray-700 dark:text-gray-300 mb-1">Mesh Connections:</div>
|
|
{meshLinks.map((mesh) => {
|
|
const otherApId = mesh.properties.start_ap_id === item.id
|
|
? mesh.properties.end_ap_id
|
|
: mesh.properties.start_ap_id;
|
|
|
|
const otherAp = otherApId
|
|
? items.find(i => i.id === otherApId)
|
|
: null;
|
|
|
|
return (
|
|
<div key={mesh.id} className="text-xs text-gray-600 dark:text-gray-400 ml-2">
|
|
→ {otherAp ? (otherAp.properties.name || otherAp.type) : 'Unknown AP'}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
|
|
{item.properties.notes && (
|
|
<div className="text-gray-600 dark:text-gray-400 mt-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
{item.properties.notes}
|
|
</div>
|
|
)}
|
|
{item.properties.image && (
|
|
<div className="mt-2">
|
|
<img
|
|
src={item.properties.image}
|
|
alt="Attachment"
|
|
className="w-full rounded border border-gray-200 dark:border-gray-700"
|
|
style={{ maxHeight: '150px', objectFit: 'contain' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Popup>
|
|
)}
|
|
</Marker>
|
|
);
|
|
}
|
|
|
|
return null;
|
|
})}
|
|
|
|
{/* Context menu rendered outside map using portal */}
|
|
{contextMenu && createPortal(
|
|
<ItemContextMenu
|
|
item={contextMenu.item}
|
|
position={contextMenu.position}
|
|
onClose={() => setContextMenu(null)}
|
|
onUpdate={loadItems}
|
|
/>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|