diff --git a/app/(main)/network-map/page.tsx b/app/(main)/network-map/page.tsx index 1bbbe5c7..45ea1033 100644 --- a/app/(main)/network-map/page.tsx +++ b/app/(main)/network-map/page.tsx @@ -671,7 +671,7 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) { ) } -function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: { +function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onPointerDown }: { n: Server & { x: number; y: number } labelScale?: number isSel: boolean; isVis: boolean; isDragged: boolean @@ -681,7 +681,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat onDblClick: () => void onHoverChange: (hov: boolean) => void onContextMenu: (e: React.MouseEvent) => void - onMouseDown: (e: React.MouseEvent) => void + onPointerDown: (e: React.PointerEvent) => void }) { const ss = STATUS_STYLE[n.status] const ts = TYPE_STYLE[n.type] @@ -692,7 +692,7 @@ function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLat transform={`translate(${n.x},${n.y})`} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} opacity={isVis ? 1 : 0.07} - onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }} + onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }} onClick={e => { e.stopPropagation(); onClick() }} onDoubleClick={e => { e.stopPropagation(); onDblClick() }} onContextMenu={e => { e.preventDefault(); e.stopPropagation(); onContextMenu(e) }} @@ -785,7 +785,7 @@ function ServiceNode({ dim, shareLabel, onClick, - onMouseDown, + onPointerDown, }: { label: string share: number @@ -801,7 +801,7 @@ function ServiceNode({ /** Подпись доли в tooltip: у вложенных сервисов — доля страны, не окна. */ shareLabel?: string onClick: () => void - onMouseDown: (e: React.MouseEvent) => void + onPointerDown: (e: React.PointerEvent) => void }) { const bw = MAP_SERVICE_NODE_W const bh = MAP_SERVICE_NODE_H @@ -812,7 +812,7 @@ function ServiceNode({ transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab", transition: isDragged ? "none" : "opacity 0.25s" }} opacity={isVis ? (dim ? 0.35 : 1) : 0.08} - onMouseDown={(e) => { e.stopPropagation(); onMouseDown(e) }} + onPointerDown={(e) => { e.stopPropagation(); onPointerDown(e) }} onClick={(e) => { e.stopPropagation(); onClick() }} > {`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`} @@ -916,12 +916,12 @@ function ServicePathList({ ) } -function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMouseDown }: { +function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onPointerDown }: { x: number; y: number wan: { name: string; isp: string; maxDl: number; maxUl: number } color: string; active: boolean; isSel: boolean; isDragged: boolean onSelect: () => void - onMouseDown: (e: React.MouseEvent) => void + onPointerDown: (e: React.PointerEvent) => void }) { const r = 18 return ( @@ -929,7 +929,7 @@ function WanSatNode({ x, y, wan, color, active, isSel, isDragged, onSelect, onMo onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();e.stopPropagation();onSelect()}}} transform={`translate(${x},${y})`} style={{ cursor: isDragged ? "grabbing" : "grab" }} - onMouseDown={e => { e.stopPropagation(); onMouseDown(e) }} + onPointerDown={e => { e.stopPropagation(); onPointerDown(e) }} onClick={e => { e.stopPropagation(); onSelect() }}> {active && ( @@ -1766,6 +1766,8 @@ function NetworkMapContent() { return () => svg.removeEventListener("wheel", handler) }, []) + const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector("[data-map-device-select]")?.focus())},[]) + // ── Zoom helpers ────────────────────────────────────────────────────────── const applyZoomCenter=useCallback((factor: number) => { const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor)) @@ -1798,6 +1800,7 @@ function NetworkMapContent() { setSelectedService(null) setHighlightedPath(null) setExpandedCountryId(null) + returnToMapControl() } if (e.key === "=" || e.key === "+") applyZoomCenter(1.25) if (e.key === "-") applyZoomCenter(1 / 1.25) @@ -1807,16 +1810,21 @@ function NetworkMapContent() { } window.addEventListener("keydown", onKey) return () => window.removeEventListener("keydown", onKey) - }, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers]) + }, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers,returnToMapControl]) // ── Drag ───────────────────────────────────────────────────────────────── - function onSvgMouseDown(e: React.MouseEvent) { + function onSvgPointerDown(e: React.PointerEvent) { + if(!e.isPrimary||e.button!==0)return + if(e.target!==e.currentTarget&&!(e.target instanceof Element&&e.target.hasAttribute("data-map-background")))return + e.currentTarget.setPointerCapture(e.pointerId) + suppressClickRef.current=false // Only pan on background clicks (node mousedown stops propagation) dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y, moved: false } setIsDragging(true) } - function onSvgMouseMove(e: React.MouseEvent) { + function onSvgPointerMove(e: React.PointerEvent) { + if(!e.isPrimary)return const nd = nodeDragRef.current if (nd) { const dx = e.clientX - nd.startX @@ -1863,8 +1871,10 @@ function NetworkMapContent() { return true } - function onSvgMouseUp() { + function onSvgPointerUp(e:React.PointerEvent) { + if(!e.isPrimary||e.button!==0)return if (finishNodeDrag()) return + if(!dragRef.current)return const moved = dragRef.current?.moved ?? false dragRef.current = null setIsDragging(false) @@ -1879,15 +1889,24 @@ function NetworkMapContent() { } // ── Node drag start ────────────────────────────────────────────────────── - function onNodeMouseDown(e: React.MouseEvent, n: Server & { x: number; y: number }) { + function onNodePointerDown(e: React.PointerEvent, n: Server & {x: number; y: number}) { + if(!e.isPrimary||e.button!==0)return + e.currentTarget.setPointerCapture(e.pointerId) + suppressClickRef.current=false nodeDragRef.current = { kind: "server", nodeId: n.id, startX: e.clientX, startY: e.clientY, origX: n.x, origY: n.y, moved: false } setDraggedNodeId(n.id) } - function onSatMouseDown(e: React.MouseEvent, homeId: string, wanIdx: number, x: number, y: number) { + function onSatPointerDown(e: React.PointerEvent, homeId: string, wanIdx: number, x: number, y: number) { + if(!e.isPrimary||e.button!==0)return + e.currentTarget.setPointerCapture(e.pointerId) + suppressClickRef.current=false nodeDragRef.current = { kind: "wan-sat", homeId, wanIdx, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } setDraggedSatKey(`${homeId}-${wanIdx}`) } - function onServiceMouseDown(e: React.MouseEvent, svcId: string, x: number, y: number) { + function onServicePointerDown(e: React.PointerEvent, svcId: string, x: number, y: number) { + if(!e.isPrimary||e.button!==0)return + e.currentTarget.setPointerCapture(e.pointerId) + suppressClickRef.current=false nodeDragRef.current = { kind: "service", svcId, startX: e.clientX, startY: e.clientY, origX: x, origY: y, moved: false } setDraggedSvcId(svcId) } @@ -2062,7 +2081,7 @@ function NetworkMapContent() { Обновить )} - @@ -2237,17 +2256,19 @@ function NetworkMapContent() { Обозначения карты

HR · домашний роутер

JH · промежуточный узел

EN · выходной узел

Круг · устройство / WAN

Прямоугольник · сервис / страна

Пунктир WAN → JH · модель связности

Цвет · статус; неизвестно ≠ оффлайн

+

Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.

{if(e.target!==e.currentTarget||e.ctrlKey||e.metaKey||e.altKey)return;const delta=40/zoom;if(e.key==="ArrowLeft"||e.key==="ArrowRight"||e.key==="ArrowUp"||e.key==="ArrowDown"){e.preventDefault();e.stopPropagation();setPan(value=>({x:value.x+(e.key==="ArrowLeft"?-delta:e.key==="ArrowRight"?delta:0),y:value.y+(e.key==="ArrowUp"?-delta:e.key==="ArrowDown"?delta:0)}))}}} viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`} - style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }} - onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }} - onMouseMove={onSvgMouseMove} - onMouseUp={onSvgMouseUp} - onMouseLeave={() => { - finishNodeDrag() - dragRef.current = null; setIsDragging(false); setHoveredId(null) - }} + style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none", touchAction:"none" }} + onPointerDown={onSvgPointerDown} + onPointerMove={onSvgPointerMove} + onPointerUp={onSvgPointerUp} + onPointerLeave={()=>setHoveredId(null)} + onPointerCancel={()=>{finishNodeDrag();suppressClickRef.current=false;dragRef.current=null;setIsDragging(false)}} + onLostPointerCapture={()=>{finishNodeDrag();dragRef.current=null;setIsDragging(false)}} > @@ -2262,7 +2283,7 @@ function NetworkMapContent() { {/* Background grid */} - + {/* ── GRE edges ── */} {greEdges.map((e, i) => { @@ -2474,7 +2495,7 @@ function NetworkMapContent() { onDblClick={() => { selectServer(n); focusNode(n.x, n.y) }} onHoverChange={h => setHoveredId(h ? n.id : null)} onContextMenu={e => setCtxMenu({ x: e.clientX, y: e.clientY, server: n })} - onMouseDown={e => onNodeMouseDown(e, n)} + onPointerDown={e => onNodePointerDown(e, n)} /> ))} @@ -2498,7 +2519,7 @@ function NetworkMapContent() { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectWan(router, wIdx) }} - onMouseDown={e => onSatMouseDown(e, router.id, wIdx, sat.x, sat.y)} + onPointerDown={e => onSatPointerDown(e, router.id, wIdx, sat.x, sat.y)} /> ) @@ -2521,7 +2542,7 @@ function NetworkMapContent() { isDragged={draggedSvcId === svc.id} destMode={destMode} iso={svc.label} - onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)} + onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)} onClick={() => { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectService(svc) @@ -2546,7 +2567,7 @@ function NetworkMapContent() { isDragged={draggedSvcId === svc.id} destMode="services" shareLabel="трафика страны" - onMouseDown={(e) => onServiceMouseDown(e, svc.id, pos.x, pos.y)} + onPointerDown={(e) => onServicePointerDown(e, svc.id, pos.x, pos.y)} onClick={() => { if (suppressClickRef.current) { suppressClickRef.current = false; return } selectService(svc) @@ -2722,7 +2743,7 @@ function NetworkMapContent() { {/* ── Keyboard hints (viewport-fixed) ── */} {showHints && ( - + {[ ["Scroll", "zoom"], ["+/-", "zoom in/out"], @@ -2745,6 +2766,10 @@ function NetworkMapContent() { )} + {useLiveData&&!hasSnapshot&&dataLoading&&

Загружаем карту…

} + {(hasSnapshot||!useLiveData)&&!nodes.length&&

Устройств на карте пока нет

В текущем источнике нет устройств для построения схемы.

Открыть серверы
} + {!!nodes.length&&!nodes.some(isVisible)&&

Узлы по заданным условиям не найдены.

} + {/* ── Zoom controls ── */}