Compare commits
8 Commits
61ba40c7d2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a4820aab18
|
|||
|
ebc7f9cb84
|
|||
|
4f0ce8744e
|
|||
|
e36e5e8fc5
|
|||
|
57adb221f9
|
|||
|
7e4bd35f3a
|
|||
|
d85849debe
|
|||
|
7eaf1b3b86
|
@@ -16,15 +16,18 @@ class MapItem(Base):
|
||||
- switch: Network switch
|
||||
- indoor_ap: Indoor access point
|
||||
- outdoor_ap: Outdoor access point
|
||||
- other_device: Other device (1 ethernet port)
|
||||
- info: Information marker
|
||||
|
||||
Geometry:
|
||||
- Point for devices (switches, APs)
|
||||
- Point for devices (switches, APs, other devices, info markers)
|
||||
- LineString for cables and wireless mesh
|
||||
|
||||
Properties (JSONB):
|
||||
- For cables: cable_type, name, notes, length_meters, start_device_id, end_device_id
|
||||
- For devices: name, notes, port_count, connections (array of {cable_id, port_number})
|
||||
- For wireless_mesh: name, notes, start_ap_id, end_ap_id
|
||||
- For info markers: name, notes, image (optional)
|
||||
"""
|
||||
|
||||
__tablename__ = "map_items"
|
||||
|
||||
@@ -10,7 +10,8 @@ from app.models.user import User
|
||||
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
|
||||
|
||||
# Storage directory for uploaded images
|
||||
STORAGE_DIR = Path("/home/shihaam/git/sarlink/mapmaker/storage/images")
|
||||
# Use relative path from project root to work in both dev and production
|
||||
STORAGE_DIR = Path(__file__).resolve().parent.parent.parent / "storage" / "images"
|
||||
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Allowed image types
|
||||
|
||||
@@ -6,7 +6,7 @@ from uuid import UUID
|
||||
|
||||
class MapItemBase(BaseModel):
|
||||
"""Base map item schema with common attributes."""
|
||||
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap")
|
||||
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap, other_device, info")
|
||||
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
|
||||
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMapEvents, Polyline, Marker } from 'react-leaflet';
|
||||
import { useMapEvents, Polyline, Marker, Circle } from 'react-leaflet';
|
||||
import { useDrawingStore } from '../../stores/drawingStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { mapItemService } from '../../services/mapItemService';
|
||||
@@ -8,9 +8,10 @@ import { CABLE_COLORS, type CableType } from '../../types/mapItem';
|
||||
interface DrawingHandlerProps {
|
||||
mapId: string;
|
||||
onItemCreated: () => void;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
export function DrawingHandler({ mapId, onItemCreated, refreshTrigger }: DrawingHandlerProps) {
|
||||
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing } =
|
||||
useDrawingStore();
|
||||
const { showToast } = useUIStore();
|
||||
@@ -18,9 +19,11 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
const [allItems, setAllItems] = useState<any[]>([]);
|
||||
const [startDeviceId, setStartDeviceId] = useState<string | null>(null);
|
||||
const [endDeviceId, setEndDeviceId] = useState<string | null>(null);
|
||||
const [nearbyDevice, setNearbyDevice] = useState<any | null>(null);
|
||||
const [nearbyFullDevice, setNearbyFullDevice] = useState<any | null>(null);
|
||||
|
||||
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
|
||||
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(activeTool);
|
||||
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(activeTool);
|
||||
const isWirelessTool = activeTool === 'wireless_mesh';
|
||||
|
||||
// Load all map items for snapping
|
||||
@@ -34,12 +37,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
}
|
||||
};
|
||||
loadItems();
|
||||
}, [mapId]);
|
||||
}, [mapId, refreshTrigger]);
|
||||
|
||||
// Find nearby device for snapping (exclude info markers)
|
||||
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 5): any | null => {
|
||||
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 2.5): any | null => {
|
||||
const devices = allItems.filter(item =>
|
||||
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
|
||||
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||
item.geometry.type === 'Point'
|
||||
);
|
||||
|
||||
@@ -78,6 +81,10 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
let { lat, lng } = e.latlng;
|
||||
let clickedDevice = null;
|
||||
|
||||
// Clear snap indicator on click
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
|
||||
// Check for nearby device when drawing cables
|
||||
if (isCableTool && map) {
|
||||
clickedDevice = findNearbyDevice(lat, lng);
|
||||
@@ -116,7 +123,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
|
||||
// Only add port_count and connections if it's not an info marker
|
||||
if (activeTool !== 'info') {
|
||||
properties.port_count = activeTool === 'switch' ? 5 : activeTool === 'outdoor_ap' ? 1 : 4;
|
||||
properties.port_count = activeTool === 'switch' ? 5 : (activeTool === 'outdoor_ap' || activeTool === 'other_device') ? 1 : 4;
|
||||
properties.connections = [];
|
||||
}
|
||||
|
||||
@@ -151,7 +158,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
// Wireless mesh - connect AP to AP only
|
||||
if (isWirelessTool) {
|
||||
// Must click on an AP
|
||||
const ap = findNearbyDevice(lat, lng, 5);
|
||||
const ap = findNearbyDevice(lat, lng, 2.5);
|
||||
if (!ap || !['indoor_ap', 'outdoor_ap'].includes(ap.type)) {
|
||||
showToast('Wireless mesh can only connect between Access Points. Please click on an AP.', 'error');
|
||||
return;
|
||||
@@ -191,6 +198,40 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
if (isDrawing && (isCableTool || isWirelessTool)) {
|
||||
setCursorPosition([e.latlng.lat, e.latlng.lng]);
|
||||
}
|
||||
|
||||
// Check for nearby device to show snap indicator
|
||||
if ((isCableTool || isWirelessTool) && map) {
|
||||
const nearby = findNearbyDevice(e.latlng.lat, e.latlng.lng);
|
||||
|
||||
// For wireless mesh, only show indicator for APs
|
||||
if (isWirelessTool) {
|
||||
if (nearby && ['indoor_ap', 'outdoor_ap'].includes(nearby.type)) {
|
||||
setNearbyDevice(nearby);
|
||||
setNearbyFullDevice(null);
|
||||
} else {
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}
|
||||
} else if (isCableTool) {
|
||||
// For cables, check port availability
|
||||
if (nearby) {
|
||||
if (hasAvailablePorts(nearby)) {
|
||||
setNearbyDevice(nearby);
|
||||
setNearbyFullDevice(null);
|
||||
} else {
|
||||
// Device has full ports - show red indicator
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(nearby);
|
||||
}
|
||||
} else {
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}
|
||||
},
|
||||
|
||||
contextmenu: async (e) => {
|
||||
@@ -214,6 +255,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
if (e.key === 'Escape' && isDrawing) {
|
||||
resetDrawing();
|
||||
setCursorPosition(null);
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -221,6 +264,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isDrawing, resetDrawing]);
|
||||
|
||||
// Clear nearby device indicator when tool changes
|
||||
useEffect(() => {
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}, [activeTool]);
|
||||
|
||||
const finishCable = async () => {
|
||||
if (drawingPoints.length < 2) return;
|
||||
|
||||
@@ -244,6 +293,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
setCursorPosition(null);
|
||||
setStartDeviceId(null);
|
||||
setEndDeviceId(null);
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
// Reload items
|
||||
const items = await mapItemService.getMapItems(mapId);
|
||||
setAllItems(items);
|
||||
@@ -283,6 +334,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
setCursorPosition(null);
|
||||
setStartDeviceId(null);
|
||||
setEndDeviceId(null);
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
// Reload items
|
||||
const items = await mapItemService.getMapItems(mapId);
|
||||
setAllItems(items);
|
||||
@@ -293,50 +346,81 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
||||
setCursorPosition(null);
|
||||
setStartDeviceId(null);
|
||||
setEndDeviceId(null);
|
||||
setNearbyDevice(null);
|
||||
setNearbyFullDevice(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Render drawing preview
|
||||
if (isDrawing && drawingPoints.length > 0) {
|
||||
const color = isCableTool
|
||||
? CABLE_COLORS[activeTool as CableType]
|
||||
: isWirelessTool
|
||||
? '#10B981'
|
||||
: '#6B7280';
|
||||
// Render drawing preview and snap indicator
|
||||
const color = isCableTool
|
||||
? CABLE_COLORS[activeTool as CableType]
|
||||
: isWirelessTool
|
||||
? '#10B981'
|
||||
: '#6B7280';
|
||||
|
||||
const dashArray = isWirelessTool ? '10, 10' : undefined;
|
||||
const dashArray = isWirelessTool ? '10, 10' : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main line connecting all points */}
|
||||
{drawingPoints.length > 1 && (
|
||||
<Polyline
|
||||
positions={drawingPoints}
|
||||
color={color}
|
||||
weight={3}
|
||||
dashArray={dashArray}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
return (
|
||||
<>
|
||||
{/* Green snap indicator - show when hovering near a device with available ports */}
|
||||
{nearbyDevice && nearbyDevice.geometry.type === 'Point' && (
|
||||
<Circle
|
||||
center={[nearbyDevice.geometry.coordinates[1], nearbyDevice.geometry.coordinates[0]]}
|
||||
radius={3}
|
||||
pathOptions={{
|
||||
color: '#10B981',
|
||||
fillColor: '#10B981',
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Preview line from last point to cursor */}
|
||||
{cursorPosition && drawingPoints.length > 0 && (
|
||||
<Polyline
|
||||
positions={[drawingPoints[drawingPoints.length - 1], cursorPosition]}
|
||||
color={color}
|
||||
weight={3}
|
||||
dashArray="5, 5"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
{/* Red snap indicator - show when hovering near a device with full ports */}
|
||||
{nearbyFullDevice && nearbyFullDevice.geometry.type === 'Point' && (
|
||||
<Circle
|
||||
center={[nearbyFullDevice.geometry.coordinates[1], nearbyFullDevice.geometry.coordinates[0]]}
|
||||
radius={3}
|
||||
pathOptions={{
|
||||
color: '#EF4444',
|
||||
fillColor: '#EF4444',
|
||||
fillOpacity: 0.2,
|
||||
weight: 2,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Markers at each point */}
|
||||
{drawingPoints.map((point, idx) => (
|
||||
<Marker key={idx} position={point} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
{/* Drawing preview - only show when actively drawing */}
|
||||
{isDrawing && drawingPoints.length > 0 && (
|
||||
<>
|
||||
{/* Main line connecting all points */}
|
||||
{drawingPoints.length > 1 && (
|
||||
<Polyline
|
||||
positions={drawingPoints}
|
||||
color={color}
|
||||
weight={3}
|
||||
dashArray={dashArray}
|
||||
opacity={0.8}
|
||||
/>
|
||||
)}
|
||||
|
||||
return null;
|
||||
{/* Preview line from last point to cursor */}
|
||||
{cursorPosition && drawingPoints.length > 0 && (
|
||||
<Polyline
|
||||
positions={[drawingPoints[drawingPoints.length - 1], cursorPosition]}
|
||||
color={color}
|
||||
weight={3}
|
||||
dashArray="5, 5"
|
||||
opacity={0.5}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Markers at each point */}
|
||||
{drawingPoints.map((point, idx) => (
|
||||
<Marker key={idx} position={point} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isSwitch = item.type === 'switch';
|
||||
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type);
|
||||
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type);
|
||||
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -88,6 +88,22 @@ const outdoorApIcon = new L.DivIcon({
|
||||
iconAnchor: [20, 40],
|
||||
});
|
||||
|
||||
const otherDeviceIcon = new L.DivIcon({
|
||||
html: `<div class="device-icon other-device-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>`,
|
||||
className: 'custom-device-marker',
|
||||
iconSize: [40, 40],
|
||||
iconAnchor: [20, 40],
|
||||
});
|
||||
|
||||
const infoIcon = new L.DivIcon({
|
||||
html: `<div class="info-marker-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -127,7 +143,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
console.log('Loaded items:', data);
|
||||
// Log devices with their connections
|
||||
data.forEach(item => {
|
||||
if (['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type)) {
|
||||
if (['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type)) {
|
||||
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
|
||||
}
|
||||
});
|
||||
@@ -153,6 +169,8 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
return indoorApIcon;
|
||||
case 'outdoor_ap':
|
||||
return outdoorApIcon;
|
||||
case 'other_device':
|
||||
return otherDeviceIcon;
|
||||
case 'info':
|
||||
return infoIcon;
|
||||
default:
|
||||
@@ -379,7 +397,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
||||
|
||||
// Render devices and info markers
|
||||
if (
|
||||
['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(item.type) &&
|
||||
['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(item.type) &&
|
||||
item.geometry.type === 'Point'
|
||||
) {
|
||||
const [lng, lat] = item.geometry.coordinates;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { DrawingHandler } from './DrawingHandler';
|
||||
import { MapItemsLayer } from './MapItemsLayer';
|
||||
import { ShareDialog } from './ShareDialog';
|
||||
import { useMapWebSocket } from '../../hooks/useMapWebSocket';
|
||||
import { mapItemService } from '../../services/mapItemService';
|
||||
|
||||
// Fix Leaflet's default icon paths for production builds
|
||||
// Since we use custom DivIcons, we just need to prevent 404s
|
||||
@@ -38,6 +39,50 @@ function MapController() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AutoZoom({ mapId }: { mapId: string }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const zoomToDevices = async () => {
|
||||
try {
|
||||
const items = await mapItemService.getMapItems(mapId);
|
||||
|
||||
// Filter only devices (exclude cables, wireless mesh, and info markers)
|
||||
const devices = items.filter(item =>
|
||||
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||
item.geometry.type === 'Point'
|
||||
);
|
||||
|
||||
if (devices.length === 0) {
|
||||
// No devices, keep default view
|
||||
return;
|
||||
}
|
||||
|
||||
// Create bounds from all device coordinates
|
||||
const bounds = L.latLngBounds(
|
||||
devices.map(device => {
|
||||
const coords = (device.geometry as GeoJSON.Point).coordinates;
|
||||
const [lng, lat] = coords;
|
||||
return [lat, lng] as [number, number];
|
||||
})
|
||||
);
|
||||
|
||||
// Fit map to bounds with padding
|
||||
map.fitBounds(bounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 18,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-zoom to devices:', error);
|
||||
}
|
||||
};
|
||||
|
||||
zoomToDevices();
|
||||
}, [mapId, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
@@ -89,6 +134,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
|
||||
style={{ background: '#f0f0f0' }}
|
||||
>
|
||||
<MapController />
|
||||
<AutoZoom mapId={mapId} />
|
||||
<TileLayer
|
||||
key={activeLayer}
|
||||
url={layer.url}
|
||||
@@ -99,7 +145,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
|
||||
|
||||
{/* Drawing handler for creating new items - disabled for read-only */}
|
||||
{permission !== 'read' && (
|
||||
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
|
||||
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
|
||||
{/* Render existing map items */}
|
||||
|
||||
@@ -71,6 +71,12 @@ const DEVICE_TOOLS: ToolButton[] = [
|
||||
icon: 'outdoor_ap',
|
||||
description: 'Outdoor Access Point',
|
||||
},
|
||||
{
|
||||
id: 'other_device',
|
||||
label: 'Other Device',
|
||||
icon: 'other_device',
|
||||
description: 'Other Device',
|
||||
},
|
||||
];
|
||||
|
||||
const INFO_TOOL: ToolButton = {
|
||||
@@ -128,6 +134,19 @@ export function Toolbar({ readOnly = false }: ToolbarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (tool.icon === 'other_device') {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6B7280' }}>
|
||||
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="2"/>
|
||||
<circle cx="12" cy="12" r="2"/>
|
||||
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (tool.icon === 'info') {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6366F1' }}>
|
||||
|
||||
@@ -138,6 +138,11 @@
|
||||
color: #F59E0B;
|
||||
}
|
||||
|
||||
.other-device-icon {
|
||||
border-color: #6B7280;
|
||||
color: #6B7280;
|
||||
}
|
||||
|
||||
/* Info marker icon */
|
||||
.custom-info-marker {
|
||||
background: transparent !important;
|
||||
|
||||
@@ -8,12 +8,6 @@ import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
||||
type MapLayer = 'osm' | 'google' | 'esri';
|
||||
|
||||
const MAP_LAYERS = {
|
||||
osm: {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 25,
|
||||
},
|
||||
google: {
|
||||
name: 'Google Satellite',
|
||||
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||
@@ -21,6 +15,12 @@ const MAP_LAYERS = {
|
||||
maxZoom: 25,
|
||||
maxNativeZoom: 22,
|
||||
},
|
||||
osm: {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 25,
|
||||
},
|
||||
esri: {
|
||||
name: 'ESRI Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
@@ -33,7 +33,7 @@ export function Dashboard() {
|
||||
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [shareMapId, setShareMapId] = useState<string | null>(null);
|
||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
const handleShareMap = (mapId: string) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Toolbar } from '../components/map/Toolbar';
|
||||
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
||||
import { apiClient } from '../services/api';
|
||||
import { useUIStore } from '../stores/uiStore';
|
||||
import { mapItemService } from '../services/mapItemService';
|
||||
|
||||
// Fix Leaflet's default icon paths for production builds
|
||||
// Since we use custom DivIcons, we just need to prevent 404s
|
||||
@@ -23,12 +24,6 @@ L.Icon.Default.mergeOptions({
|
||||
type MapLayer = 'osm' | 'google' | 'esri';
|
||||
|
||||
const MAP_LAYERS = {
|
||||
osm: {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 25,
|
||||
},
|
||||
google: {
|
||||
name: 'Google Satellite',
|
||||
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||
@@ -36,6 +31,12 @@ const MAP_LAYERS = {
|
||||
maxZoom: 25,
|
||||
maxNativeZoom: 22,
|
||||
},
|
||||
osm: {
|
||||
name: 'OpenStreetMap',
|
||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 25,
|
||||
},
|
||||
esri: {
|
||||
name: 'ESRI Satellite',
|
||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
@@ -56,10 +57,54 @@ function MapController() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function AutoZoom({ mapId }: { mapId: string }) {
|
||||
const map = useMap();
|
||||
|
||||
useEffect(() => {
|
||||
const zoomToDevices = async () => {
|
||||
try {
|
||||
const items = await mapItemService.getMapItems(mapId);
|
||||
|
||||
// Filter only devices (exclude cables, wireless mesh, and info markers)
|
||||
const devices = items.filter(item =>
|
||||
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||
item.geometry.type === 'Point'
|
||||
);
|
||||
|
||||
if (devices.length === 0) {
|
||||
// No devices, keep default view
|
||||
return;
|
||||
}
|
||||
|
||||
// Create bounds from all device coordinates
|
||||
const bounds = L.latLngBounds(
|
||||
devices.map(device => {
|
||||
const coords = (device.geometry as GeoJSON.Point).coordinates;
|
||||
const [lng, lat] = coords;
|
||||
return [lat, lng] as [number, number];
|
||||
})
|
||||
);
|
||||
|
||||
// Fit map to bounds with padding
|
||||
map.fitBounds(bounds, {
|
||||
padding: [50, 50],
|
||||
maxZoom: 18,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-zoom to devices:', error);
|
||||
}
|
||||
};
|
||||
|
||||
zoomToDevices();
|
||||
}, [mapId, map]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SharedMap() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { darkMode, toggleDarkMode } = useUIStore();
|
||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [mapData, setMapData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -232,6 +277,7 @@ export function SharedMap() {
|
||||
style={{ background: '#f0f0f0' }}
|
||||
>
|
||||
<MapController />
|
||||
<AutoZoom mapId={mapData.id} />
|
||||
<TileLayer
|
||||
key={activeLayer}
|
||||
url={MAP_LAYERS[activeLayer].url}
|
||||
@@ -242,7 +288,7 @@ export function SharedMap() {
|
||||
|
||||
{/* Drawing handler for edit access */}
|
||||
{!isReadOnly && (
|
||||
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
|
||||
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
|
||||
)}
|
||||
|
||||
{/* Render existing map items */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'wireless_mesh' | 'info';
|
||||
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'other_device' | 'wireless_mesh' | 'info';
|
||||
|
||||
export type CableType = 'fiber' | 'cat6' | 'cat6_poe';
|
||||
|
||||
@@ -60,6 +60,7 @@ export type DrawingTool =
|
||||
| 'switch'
|
||||
| 'indoor_ap'
|
||||
| 'outdoor_ap'
|
||||
| 'other_device'
|
||||
| 'wireless_mesh'
|
||||
| 'info';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user