fix(ui): завершить доступное управление картой сети
This commit is contained in:
1 parent
bc83b8dbbf
commit
bc20e5b9f4
11 files changed
+116
-37
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user