fix(ui): завершить доступное управление картой сети

This commit is contained in:
Denozordec committed 2026-10-01 03:26:31 +07:00
1 parent bc83b8dbbf
commit bc20e5b9f4
11 files changed
+116 -37

No files matched your search

+60 -35
View File
@@ -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<SVGGElement>) => 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<SVGGElement>) => 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() }}
>
<title>{`${label} · ${serviceSharePct(share)} ${shareLabel ?? "payload окна"}`}</title>
@@ -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<SVGGElement>) => 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 && (
<circle r={r + 6} fill={color} opacity="0.12">
@@ -1766,6 +1766,8 @@ function NetworkMapContent() {
return () => svg.removeEventListener("wheel", handler)
}, [])
const returnToMapControl=useCallback(()=>{queueMicrotask(()=>document.querySelector<HTMLSelectElement>("[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<SVGSVGElement>) {
function onSvgPointerDown(e: React.PointerEvent<SVGSVGElement>) {
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<SVGSVGElement>) {
function onSvgPointerMove(e: React.PointerEvent<SVGSVGElement>) {
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<SVGSVGElement>) {
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<SVGGElement>, 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<SVGGElement>, 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<SVGGElement>, 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() {
Обновить
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={exportSvg}>
<Button type="button" variant="outline" size="sm" onClick={exportSvg} disabled={useLiveData&&(!hasSnapshot||!mapServers.length)}>
<DownloadIcon aria-hidden="true" className="size-4" />Экспорт SVG
</Button>
</>
@@ -2237,17 +2256,19 @@ function NetworkMapContent() {
<summary className="cursor-pointer focus-visible:outline-2 focus-visible:outline-ring">Обозначения карты</summary>
<div className="mt-2 space-y-1"><p>HR · домашний роутер</p><p>JH · промежуточный узел</p><p>EN · выходной узел</p><p>Круг · устройство / WAN</p><p>Прямоугольник · сервис / страна</p><p>Пунктир WAN → JH · модель связности</p><p>Цвет · статус; неизвестно ≠ оффлайн</p></div>
</details>
<p id="network-map-help" className="sr-only">Стрелки перемещают карту, плюс и минус меняют масштаб, F вписывает объекты. Tab выбирает объект, Enter открывает детали.</p>
<svg
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
ref={svgRef} role="group" aria-label="Интерактивная карта сети" tabIndex={0} aria-describedby="network-map-help"
className="focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={e=>{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)}}
>
<defs>
<pattern id="map-dots" width="22" height="22" patternUnits="userSpaceOnUse">
@@ -2262,7 +2283,7 @@ function NetworkMapContent() {
</defs>
{/* Background grid */}
<rect width={W} height={H} fill="url(#map-dots)" />
<rect data-map-background x={pan.x} y={pan.y} width={W/zoom} height={viewportHeight/zoom} fill="url(#map-dots)" />
{/* ── 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)}
/>
</g>
)
@@ -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 && (
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + viewportHeight / zoom - 14})`}>
{[
["Scroll", "zoom"],
["+/-", "zoom in/out"],
@@ -2745,6 +2766,10 @@ function NetworkMapContent() {
)}
</svg>
{useLiveData&&!hasSnapshot&&dataLoading&&<p role="status" className="absolute inset-0 flex items-center justify-center bg-background/80 text-sm">Загружаем карту…</p>}
{(hasSnapshot||!useLiveData)&&!nodes.length&&<div role="status" className="absolute inset-0 flex flex-col gap-2 items-center justify-center bg-background/90 p-4 text-center"><p className="font-medium">Устройств на карте пока нет</p><p className="text-sm text-muted-foreground">В текущем источнике нет устройств для построения схемы.</p><Link href="/servers" className="text-sm underline">Открыть серверы</Link></div>}
{!!nodes.length&&!nodes.some(isVisible)&&<div role="status" className="absolute left-3 right-3 top-16 rounded-md border bg-popover p-3 text-sm"><p>Узлы по заданным условиям не найдены.</p><Button type="button" variant="outline" size="sm" className="mt-2" onClick={()=>updateUrl({q:null,filter:null})}>Сбросить срез карты</Button></div>}
{/* ── Zoom controls ── */}
<ZoomControls
zoom={zoom}
@@ -2815,7 +2840,7 @@ function NetworkMapContent() {
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button"
aria-label="Закрыть детали GRE" onClick={() => setSelectedGreEdge(null)}
aria-label="Закрыть детали GRE" onClick={() => {setSelectedGreEdge(null);returnToMapControl()}}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>
@@ -3011,7 +3036,7 @@ function NetworkMapContent() {
</p>
</div>
<button type="button"
aria-label="Закрыть детали назначения" onClick={() => setSelectedService(null)}
aria-label="Закрыть детали назначения" onClick={() => {setSelectedService(null);returnToMapControl()}}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<XIcon aria-hidden="true" className="size-4" />
@@ -3206,7 +3231,7 @@ function NetworkMapContent() {
aria-label="Навести на карте" title="Навести на карте">
<HomeIcon aria-hidden="true" className="size-3.5" />
</button>
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null) }}
<button type="button" aria-label="Закрыть детали устройства" onClick={() => { setSelected(null); setSelWanIdx(null); returnToMapControl() }}
className="text-muted-foreground hover:text-foreground transition-colors">
<XIcon aria-hidden="true" className="size-4" />
</button>