fix(ui): вписывать топологию в область карты
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
|
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
|
||||||
|
import {fitNetworkMapCamera} from "@/lib/network-map-camera"
|
||||||
import { useSearchParams } from "next/navigation"
|
import { useSearchParams } from "next/navigation"
|
||||||
import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state"
|
import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state"
|
||||||
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||||
@@ -441,9 +442,9 @@ function latencyColor(ms: number | null) {
|
|||||||
if (ms > 60) return "var(--status-degraded)"
|
if (ms > 60) return "var(--status-degraded)"
|
||||||
return "var(--status-online)"
|
return "var(--status-online)"
|
||||||
}
|
}
|
||||||
function shortName(name: string) {
|
function shortName(name: string, limit = 24) {
|
||||||
const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
|
const label = name.replace(/^(home|mt)-/, "").split("-").slice(0, 3).join("-")
|
||||||
return label.length > 24 ? label.slice(0, 23).trimEnd() + "…" : label
|
return label.length > limit ? label.slice(0, limit - 1).trimEnd() + "…" : label
|
||||||
}
|
}
|
||||||
|
|
||||||
function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus {
|
function greStatusToServerStatus(s: "up" | "down" | "degraded"): ServerStatus {
|
||||||
@@ -646,7 +647,7 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
|||||||
const oy = n.y - 32
|
const oy = n.y - 32
|
||||||
const lines = [
|
const lines = [
|
||||||
n.name,
|
n.name,
|
||||||
`${TYPE_LABELS[n.type]} · ${n.site}`,
|
`${TYPE_LABELS[n.type]} · ${n.site || n.country?.toUpperCase() || "—"}`,
|
||||||
n.latency != null ? `${n.latency} мс` : "Офлайн",
|
n.latency != null ? `${n.latency} мс` : "Офлайн",
|
||||||
n.model ?? "",
|
n.model ?? "",
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
@@ -670,8 +671,9 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
|
function ServerNode({ labelScale = 1, n, isSel, isVis, isDragged, hideCatalogLatency, onClick, onDblClick, onHoverChange, onContextMenu, onMouseDown }: {
|
||||||
n: Server & { x: number; y: number }
|
n: Server & { x: number; y: number }
|
||||||
|
labelScale?: number
|
||||||
isSel: boolean; isVis: boolean; isDragged: boolean
|
isSel: boolean; isVis: boolean; isDragged: boolean
|
||||||
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
|
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
|
||||||
hideCatalogLatency?: boolean
|
hideCatalogLatency?: boolean
|
||||||
@@ -749,13 +751,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
|||||||
lineHeight: 1.25,
|
lineHeight: 1.25,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{n.site}
|
{n.site || n.country?.toUpperCase() || "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
) : (
|
) : (
|
||||||
<text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
|
<text textAnchor="middle" y="-4" fontSize={ts.r > 32 ? "13" : "11"} fontWeight="700"
|
||||||
fill="var(--foreground)" fontFamily="ui-monospace,monospace">{n.site}</text>
|
fill="var(--foreground)" fontFamily="ui-monospace,monospace">{n.site || n.country?.toUpperCase() || "—"}</text>
|
||||||
)}
|
)}
|
||||||
{!hideCatalogLatency && (
|
{!hideCatalogLatency && (
|
||||||
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
|
<text textAnchor="middle" y="12" fontSize="8.5" fill={latencyColor(n.latency)} fontFamily="ui-monospace,monospace">
|
||||||
@@ -763,8 +765,8 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
|||||||
</text>
|
</text>
|
||||||
)}
|
)}
|
||||||
{(n.name && (
|
{(n.name && (
|
||||||
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="var(--muted-foreground)"
|
<text textAnchor="middle" y={ts.r + 18} fontSize={Math.max(8.5, 11 * labelScale)} fill="var(--muted-foreground)"
|
||||||
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
|
fontFamily="ui-monospace,monospace">{shortName(n.name,labelScale>1?12:24)}</text>
|
||||||
))}
|
))}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -1005,7 +1007,8 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
|
|||||||
|
|
||||||
const MM_W = 172, MM_H = 94
|
const MM_W = 172, MM_H = 94
|
||||||
|
|
||||||
function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
|
function Minimap({ viewportHeight, pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters, servicePos, onClose, onPan }: {
|
||||||
|
viewportHeight: number
|
||||||
pan: { x: number; y: number }; zoom: number
|
pan: { x: number; y: number }; zoom: number
|
||||||
nodes: (Server & { x: number; y: number })[]
|
nodes: (Server & { x: number; y: number })[]
|
||||||
greEdges: GreMapEdge[]
|
greEdges: GreMapEdge[]
|
||||||
@@ -1017,7 +1020,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
|||||||
onPan: (x: number, y: number) => void
|
onPan: (x: number, y: number) => void
|
||||||
}) {
|
}) {
|
||||||
const vpW = Math.min(W, W / zoom)
|
const vpW = Math.min(W, W / zoom)
|
||||||
const vpH = Math.min(H, H / zoom)
|
const vpH = Math.min(H, viewportHeight / zoom)
|
||||||
|
|
||||||
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -1070,7 +1073,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
|||||||
fill="var(--card)" stroke="var(--chart-4)" strokeWidth="3" opacity="0.85" />
|
fill="var(--card)" stroke="var(--chart-4)" strokeWidth="3" opacity="0.85" />
|
||||||
))}
|
))}
|
||||||
{/* viewport rect */}
|
{/* viewport rect */}
|
||||||
<rect x={pan.x} y={pan.y} width={W / zoom} height={H / zoom}
|
<rect x={pan.x} y={pan.y} width={W / zoom} height={viewportHeight / zoom}
|
||||||
fill="var(--muted)" fillOpacity="0.1" stroke="var(--foreground)" strokeOpacity="0.6" strokeWidth="6" rx="6" />
|
fill="var(--muted)" fillOpacity="0.1" stroke="var(--foreground)" strokeOpacity="0.6" strokeWidth="6" rx="6" />
|
||||||
</svg>
|
</svg>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -1290,6 +1293,9 @@ function NetworkMapContent() {
|
|||||||
// ── View state ──────────────────────────────────────────────────────────────
|
// ── View state ──────────────────────────────────────────────────────────────
|
||||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||||
const [zoom, setZoom] = useState(1)
|
const [zoom, setZoom] = useState(1)
|
||||||
|
const [viewportHeight,setViewportHeight]=useState(H)
|
||||||
|
const [canvasMeasured,setCanvasMeasured]=useState(false)
|
||||||
|
const [canvasWidth,setCanvasWidth]=useState(W)
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
|
||||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||||
@@ -1680,6 +1686,9 @@ function NetworkMapContent() {
|
|||||||
|
|
||||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||||
const svgRef = useRef<SVGSVGElement>(null)
|
const svgRef = useRef<SVGSVGElement>(null)
|
||||||
|
const canvasRef=useRef<HTMLDivElement>(null)
|
||||||
|
const initialFit=useRef(false)
|
||||||
|
useEffect(()=>{const element=canvasRef.current;if(!element)return;const observer=new ResizeObserver(entries=>{const {width,height}=entries[0].contentRect;if(width<=0||height<=0)return;setViewportHeight(W*height/width);setCanvasWidth(width);setCanvasMeasured(true)});observer.observe(element);return()=>observer.disconnect()},[])
|
||||||
|
|
||||||
// Canvas pan drag
|
// Canvas pan drag
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -1742,14 +1751,14 @@ function NetworkMapContent() {
|
|||||||
const p = panRef.current
|
const p = panRef.current
|
||||||
const rect = svg.getBoundingClientRect()
|
const rect = svg.getBoundingClientRect()
|
||||||
const svgX = p.x + (e.clientX - rect.left) / rect.width * (W / z)
|
const svgX = p.x + (e.clientX - rect.left) / rect.width * (W / z)
|
||||||
const svgY = p.y + (e.clientY - rect.top) / rect.height * (H / z)
|
const svgY = p.y + (e.clientY - rect.top) / rect.height * ((W * rect.height / rect.width) / z)
|
||||||
// trackpad pinch sends ctrlKey, use smaller step
|
// trackpad pinch sends ctrlKey, use smaller step
|
||||||
const step = e.ctrlKey ? 1.06 : 1.14
|
const step = e.ctrlKey ? 1.06 : 1.14
|
||||||
const factor = e.deltaY < 0 ? step : 1 / step
|
const factor = e.deltaY < 0 ? step : 1 / step
|
||||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
|
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
|
||||||
setPan({
|
setPan({
|
||||||
x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom),
|
x: svgX - (e.clientX - rect.left) / rect.width * (W / newZoom),
|
||||||
y: svgY - (e.clientY - rect.top) / rect.height * (H / newZoom),
|
y: svgY - (e.clientY - rect.top) / rect.height * ((W * rect.height / rect.width) / newZoom),
|
||||||
})
|
})
|
||||||
setZoom(newZoom)
|
setZoom(newZoom)
|
||||||
}
|
}
|
||||||
@@ -1762,19 +1771,18 @@ function NetworkMapContent() {
|
|||||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||||||
setPan(p => ({
|
setPan(p => ({
|
||||||
x: p.x + (W / zoom - W / newZoom) / 2,
|
x: p.x + (W / zoom - W / newZoom) / 2,
|
||||||
y: p.y + (H / zoom - H / newZoom) / 2,
|
y: p.y + (viewportHeight / zoom - viewportHeight / newZoom) / 2,
|
||||||
}))
|
}))
|
||||||
setZoom(newZoom)
|
setZoom(newZoom)
|
||||||
},[zoom])
|
},[zoom,viewportHeight])
|
||||||
|
|
||||||
const fitView=useCallback(() => {
|
const {pan:{x:fitX,y:fitY},zoom:fitZoom}=fitNetworkMapCamera([...Object.values(nodePosById),...Object.values(effectiveSatPos).flat(),...Object.values(servicePosById),...Object.values(nestedPosById)],W,viewportHeight)
|
||||||
setPan({ x: 0, y: 0 })
|
function fitView(){setPan({x:fitX,y:fitY});setZoom(fitZoom)}
|
||||||
setZoom(1)
|
useEffect(()=>{if(initialFit.current||!canvasMeasured||!mapServers.length)return;initialFit.current=true;queueMicrotask(()=>{setPan({x:fitX,y:fitY});setZoom(fitZoom)})},[canvasMeasured,mapServers.length,fitX,fitY,fitZoom])
|
||||||
},[])
|
|
||||||
|
|
||||||
function focusNode(x: number, y: number) {
|
function focusNode(x: number, y: number) {
|
||||||
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
const newZoom = Math.min(ZOOM_MAX, Math.max(zoom * 1.8, 2))
|
||||||
setPan({ x: x - W / (2 * newZoom), y: y - H / (2 * newZoom) })
|
setPan({ x: x - W / (2 * newZoom), y: y - viewportHeight / (2 * newZoom) })
|
||||||
setZoom(newZoom)
|
setZoom(newZoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1793,13 +1801,13 @@ function NetworkMapContent() {
|
|||||||
}
|
}
|
||||||
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
if (e.key === "=" || e.key === "+") applyZoomCenter(1.25)
|
||||||
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
if (e.key === "-") applyZoomCenter(1 / 1.25)
|
||||||
if (e.key === "0" || e.key.toLowerCase() === "f") fitView()
|
if (e.key === "0" || e.key.toLowerCase() === "f") {setPan({x:fitX,y:fitY});setZoom(fitZoom)}
|
||||||
if (e.key.toLowerCase() === "m") setShowMinimap(v => !v)
|
if (e.key.toLowerCase() === "m") setShowMinimap(v => !v)
|
||||||
if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v)
|
if (e.key.toLowerCase() === "p") setShowPingBadges(v => !v)
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", onKey)
|
window.addEventListener("keydown", onKey)
|
||||||
return () => window.removeEventListener("keydown", onKey)
|
return () => window.removeEventListener("keydown", onKey)
|
||||||
}, [applyZoomCenter,fitView,setSelected,updateUrl,ctxMenu,showLayers])
|
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers])
|
||||||
|
|
||||||
// ── Drag ─────────────────────────────────────────────────────────────────
|
// ── Drag ─────────────────────────────────────────────────────────────────
|
||||||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
||||||
@@ -1816,7 +1824,7 @@ function NetworkMapContent() {
|
|||||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const svgDx = dx * (W / zoom) / rect.width
|
const svgDx = dx * (W / zoom) / rect.width
|
||||||
const svgDy = dy * (H / zoom) / rect.height
|
const svgDy = dy * (viewportHeight / zoom) / rect.height
|
||||||
const nx = nd.origX + svgDx
|
const nx = nd.origX + svgDx
|
||||||
const ny = nd.origY + svgDy
|
const ny = nd.origY + svgDy
|
||||||
|
|
||||||
@@ -1841,7 +1849,7 @@ function NetworkMapContent() {
|
|||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
setPan({
|
setPan({
|
||||||
x: dragRef.current.panX - dx * (W / zoom / rect.width),
|
x: dragRef.current.panX - dx * (W / zoom / rect.width),
|
||||||
y: dragRef.current.panY - dy * (H / zoom / rect.height),
|
y: dragRef.current.panY - dy * (viewportHeight / zoom / rect.height),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2224,10 +2232,14 @@ function NetworkMapContent() {
|
|||||||
<FramePanel className="flex flex-1 min-h-0 min-w-0 p-0 flex-col lg:flex-row">
|
<FramePanel className="flex flex-1 min-h-0 min-w-0 p-0 flex-col lg:flex-row">
|
||||||
|
|
||||||
{/* ── Canvas ── */}
|
{/* ── Canvas ── */}
|
||||||
<div className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
|
<div ref={canvasRef} className={cn("flex-1 min-h-0 min-w-0 relative overflow-hidden bg-background select-none", (selected || selectedGreEdge || selectedService) && "hidden lg:block")}>
|
||||||
|
<details className="absolute top-3 left-3 z-20 rounded-md border bg-popover p-2 text-xs shadow-sm">
|
||||||
|
<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>
|
||||||
<svg
|
<svg
|
||||||
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
|
ref={svgRef} role="group" aria-label="Интерактивная карта сети"
|
||||||
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${H / zoom}`}
|
viewBox={`${pan.x} ${pan.y} ${W / zoom} ${viewportHeight / zoom}`}
|
||||||
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
|
style={{ width: "100%", height: "100%", display: "block", cursor: isDragging ? "grabbing" : "grab", userSelect: "none", WebkitUserSelect: "none" }}
|
||||||
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
|
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
|
||||||
onMouseMove={onSvgMouseMove}
|
onMouseMove={onSvgMouseMove}
|
||||||
@@ -2449,7 +2461,7 @@ function NetworkMapContent() {
|
|||||||
|
|
||||||
{/* ── Server nodes ── */}
|
{/* ── Server nodes ── */}
|
||||||
{nodes.map(n => (
|
{nodes.map(n => (
|
||||||
<ServerNode
|
<ServerNode labelScale={W/(canvasWidth*zoom)}
|
||||||
key={n.id} n={n}
|
key={n.id} n={n}
|
||||||
isSel={selected?.id === n.id}
|
isSel={selected?.id === n.id}
|
||||||
isVis={isVisible(n)}
|
isVis={isVisible(n)}
|
||||||
@@ -2708,63 +2720,6 @@ function NetworkMapContent() {
|
|||||||
<SvgTooltip n={hoveredNode} />
|
<SvgTooltip n={hoveredNode} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Legend (viewport-fixed) ── */}
|
|
||||||
<g className="hidden lg:block" aria-hidden="true" transform={`translate(${pan.x + 14}, ${pan.y + 14})`}>
|
|
||||||
<rect width="140" height="250" rx="8"
|
|
||||||
fill="var(--popover)" stroke="var(--border)" strokeWidth="1" />
|
|
||||||
<text x="10" y="22" fontSize="8" fontWeight="700" fill="var(--muted-foreground)"
|
|
||||||
fontFamily="system-ui" letterSpacing="0.08em">ЛЕГЕНДА</text>
|
|
||||||
|
|
||||||
{(["online","degraded","offline","unknown"] as const).map((s, i) => (
|
|
||||||
<g key={s} transform={`translate(10, ${30 + i * 16})`}>
|
|
||||||
<circle cx="5" cy="0" r="4.5" fill={STATUS_STYLE[s].stroke} opacity="0.85" />
|
|
||||||
<text x="16" y="4" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">
|
|
||||||
{s === "online" ? "Онлайн" : s === "degraded" ? "Внимание" : s === "offline" ? "Оффлайн" : "Неизвестно"}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<line x1="10" y1="100" x2="130" y2="100" stroke="var(--border)" strokeWidth="1" />
|
|
||||||
|
|
||||||
{[
|
|
||||||
{ fill: "var(--chart-1)", label: "JH", text: "JumpHost" },
|
|
||||||
{ fill: "var(--chart-3)", label: "EN", text: "Exit Node" },
|
|
||||||
{ fill: "var(--chart-2)", label: "HR", text: "Home Router" },
|
|
||||||
].map((t, i) => (
|
|
||||||
<g key={t.label} transform={`translate(10, ${110 + i * 18})`}>
|
|
||||||
<rect width="14" height="14" rx="3" fill={t.fill} />
|
|
||||||
<text x="5.5" y="11" fontSize="6" fontWeight="bold" fill="var(--primary-foreground)"
|
|
||||||
textAnchor="middle" fontFamily="system-ui">{t.label}</text>
|
|
||||||
<text x="22" y="11" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">{t.text}</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<g transform="translate(10, 164)">
|
|
||||||
<rect width="14" height="14" rx="4" fill="var(--card)" stroke="var(--chart-4)" strokeWidth="1.2" />
|
|
||||||
<text x="22" y="11" fontSize="8.5" fill="var(--foreground)" fontFamily="system-ui">
|
|
||||||
{destMode === "countries" ? "Страна" : "Сервис"}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<line x1="10" y1="186" x2="130" y2="186" stroke="var(--border)" strokeWidth="1" />
|
|
||||||
|
|
||||||
<text x="10" y="200" fontSize="7.5" fontWeight="700" fill="var(--muted-foreground)"
|
|
||||||
fontFamily="system-ui" letterSpacing="0.05em">WAN АПЛИНКИ</text>
|
|
||||||
{WAN_COLORS.slice(0, 2).map((c, i) => (
|
|
||||||
<g key={i} transform={`translate(10, ${210 + i * 14})`}>
|
|
||||||
<circle cx="5" cy="4" r="4" fill={c} opacity="0.9" />
|
|
||||||
<text x="16" y="8" fontSize="8" fill="var(--muted-foreground)" fontFamily="ui-monospace,monospace">
|
|
||||||
WAN{i + 1}
|
|
||||||
</text>
|
|
||||||
<line x1="38" y1="4" x2="60" y2="4" stroke={c}
|
|
||||||
strokeWidth={i === 0 ? 2 : 1.2} strokeDasharray={i === 0 ? "none" : "4 3"} opacity="0.8" />
|
|
||||||
<text x="66" y="8" fontSize="7.5" fill="var(--muted-foreground)" fontFamily="system-ui">
|
|
||||||
{useLiveData?"модель":i === 0 ? "primary (демо)" : "backup (демо)"}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
|
|
||||||
{/* ── Keyboard hints (viewport-fixed) ── */}
|
{/* ── Keyboard hints (viewport-fixed) ── */}
|
||||||
{showHints && (
|
{showHints && (
|
||||||
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
|
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
|
||||||
@@ -2800,7 +2755,7 @@ function NetworkMapContent() {
|
|||||||
|
|
||||||
{/* ── Minimap ── */}
|
{/* ── Minimap ── */}
|
||||||
{showMinimap && (
|
{showMinimap && (
|
||||||
<Minimap
|
<Minimap viewportHeight={viewportHeight}
|
||||||
pan={pan} zoom={zoom}
|
pan={pan} zoom={zoom}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
greEdges={greEdges}
|
greEdges={greEdges}
|
||||||
@@ -3308,7 +3263,8 @@ function NetworkMapContent() {
|
|||||||
const isActive = myEdges.some(e => e.active)
|
const isActive = myEdges.some(e => e.active)
|
||||||
const isSel = selWanIdx === wIdx
|
const isSel = selWanIdx === wIdx
|
||||||
return (
|
return (
|
||||||
<div key={wan.id}
|
<div key={wan.id} role="button" tabIndex={0} aria-label={"WAN "+wan.name} aria-pressed={isSel}
|
||||||
|
onKeyDown={e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();setSelWanIdx(prev=>prev===wIdx?null:wIdx)}}}
|
||||||
onClick={() => setSelWanIdx(prev => prev === wIdx ? null : wIdx)}
|
onClick={() => setSelWanIdx(prev => prev === wIdx ? null : wIdx)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-lg border p-3 cursor-pointer transition-all",
|
"rounded-lg border p-3 cursor-pointer transition-all",
|
||||||
|
|||||||
@@ -189,4 +189,4 @@ HTTP-фикстуры; локальная приёмка: 43 основных +
|
|||||||
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
|
| Оболочка · [Frame](https://reui.io/components/frame?ref=mcp) / [API](https://reui.io/docs/components/base/frame?ref=mcp) | [Обзор](ux-ui/screens/network-map-overview-desktop.png) | [390 px](ux-ui/screens/network-map-overview-mobile.png), [320 px](ux-ui/screens/network-map-overview-narrow.png) | Источник → свежесть → слои/фильтры → карта |
|
||||||
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
|
| Детали · Frame / [List 9](https://reui.io/preview/base/list-9?ref=mcp) | [Детали](ux-ui/screens/network-map-detail-desktop.png) | [390 px](ux-ui/screens/network-map-detail-mobile.png), [320 px](ux-ui/screens/network-map-detail-narrow.png) | Устройство → фактические поля → действия → возврат |
|
||||||
|
|
||||||
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Масштаб малой топологии требует следующего блока; это промежуточные снимки, не итоговая приёмка карты.
|
Оболочка и клавиатурные действия проверены (35 + 12 E2E). Камера и читаемость малой топологии доработаны в 10.53 (16 E2E + 2 unit). Следом pointer/touch и пустые состояния; итоговая приёмка карты ещё не завершена.
|
||||||
@@ -26,12 +26,20 @@
|
|||||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.52; оптимизатор принят локально, карта сети в работе |
|
| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.53; оптимизатор принят локально, карта сети в работе |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.53 — камера и читаемость карты (2026-10-01)
|
||||||
|
|
||||||
|
- ViewBox учитывает реальное соотношение сторон canvas; начальное вписывание включает устройства, WAN, сервисы и подписи. Оно выполняется один раз на источник и не сбрасывает ручной zoom после обновления API. Кнопка/горячая клавиша вписывают текущие объекты; wheel/drag используют ту же геометрию.
|
||||||
|
- Название вынесено под узел; на телефоне размер текста компенсирует SVG масштаб, длинная подпись сокращена при сохранённом полном accessible name/деталях. Страна обозначена ISO при отсутствии площадки и до загрузки внешнего изображения флага. Легенда — доступные раскрываемые обозначения вне SVG.
|
||||||
|
- WAN карточка получила Enter/Space и aria-pressed. 2 camera unit + 16 E2E layout/details/URL, TypeScript и lint прошли; после ISO fallback повторены 5 layout E2E, TypeScript и lint. Снимки обновлены.
|
||||||
|
- Далее pointer/touch, закрытие деталей с возвратом фокуса, пустой inventory/пустой срез; затем production build и локальная приёмка карты. Реальная сеть не проверена.
|
||||||
|
|
||||||
## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01)
|
## Блок 10.52 — Frame, доступные действия и детали карты (2026-10-01)
|
||||||
|
|
||||||
- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px.
|
- Установленный ReUI Frame объединяет toolbar, SVG и детали с parent-owned высотой. На ПК детали справа; на телефоне занимают рабочую область, закрытие возвращает карту. Выбор устройства доступен вне SVG, длинные имена переносятся по словам, отсутствует горизонтальный overflow на 1440/390/320 px.
|
||||||
|
|||||||
@@ -293,3 +293,11 @@ Shared контракт и backend observed исходные значения/д
|
|||||||
- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан.
|
- SVG использует semantic tokens; reduced motion отключает animateMotion. Экспорт сохраняет CSS переменные/фон и сериализуется XMLSerializer как standalone SVG. Nullable UL не выводится строкой null; неизвестный IPsec явно подписан.
|
||||||
- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально.
|
- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально.
|
||||||
- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены.
|
- Далее 10.53: камера по границам объектов и читаемость малой топологии, pointer/touch и пустые состояния; текущие overview снимки ещё показывают слишком мелкий граф. Production build — после этого блока. Реальные RouterOS/JWT/БД не проверены.
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 10.53 — камера и читаемость карты (2026-10-01)
|
||||||
|
|
||||||
|
- ViewBox учитывает реальное соотношение сторон canvas; начальное вписывание включает устройства, WAN, сервисы и подписи. Оно выполняется один раз на источник и не сбрасывает ручной zoom после обновления API. Кнопка/горячая клавиша вписывают текущие объекты; wheel/drag используют ту же геометрию.
|
||||||
|
- Название вынесено под узел; на телефоне размер текста компенсирует SVG масштаб, длинная подпись сокращена при сохранённом полном accessible name/деталях. Страна обозначена ISO при отсутствии площадки и до загрузки внешнего изображения флага. Легенда — доступные раскрываемые обозначения вне SVG.
|
||||||
|
- WAN карточка получила Enter/Space и aria-pressed. 2 camera unit + 16 E2E layout/details/URL, TypeScript и lint прошли; после ISO fallback повторены 5 layout E2E, TypeScript и lint. Снимки обновлены.
|
||||||
|
- Далее pointer/touch, закрытие деталей с возвратом фокуса, пустой inventory/пустой срез; затем production build и локальная приёмка карты. Реальная сеть не проверена.
|
||||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 144 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 57 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
import {test} from "node:test"
|
||||||
|
import assert from "node:assert/strict"
|
||||||
|
import {fitNetworkMapCamera} from "./network-map-camera"
|
||||||
|
test("camera fits off-world nodes and labels across aspect ratios",()=>{for(const h of [580,1600]){const points=[{x:-200,y:50},{x:1000,y:700}],c=fitNetworkMapCamera(points,1240,h);for(const p of points){assert.ok(p.x>=c.pan.x+119);assert.ok(p.x<=c.pan.x+1240/c.zoom-119);assert.ok(p.y>=c.pan.y+99);assert.ok(p.y<=c.pan.y+h/c.zoom-99)}}})
|
||||||
|
test("empty/invalid positions and maximum initial zoom",()=>{assert.deepEqual(fitNetworkMapCamera([],1240,580),{pan:{x:0,y:0},zoom:1});assert.deepEqual(fitNetworkMapCamera([{x:NaN,y:1}],1240,580),{pan:{x:0,y:0},zoom:1});assert.equal(fitNetworkMapCamera([{x:1,y:1}],1240,1600).zoom,3)})
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export interface MapPoint {x:number;y:number}
|
||||||
|
export function fitNetworkMapCamera(points:readonly MapPoint[],width:number,height:number) {
|
||||||
|
const valid=points.filter(p=>Number.isFinite(p.x)&&Number.isFinite(p.y))
|
||||||
|
if(!valid.length||width<=0||height<=0)return {pan:{x:0,y:0},zoom:1}
|
||||||
|
const xs=valid.map(p=>p.x),ys=valid.map(p=>p.y),left=Math.min(...xs)-120,right=Math.max(...xs)+120,top=Math.min(...ys)-100,bottom=Math.max(...ys)+100
|
||||||
|
const zoom=Math.max(.2,Math.min(3,width/(right-left),height/(bottom-top)))
|
||||||
|
return {pan:{x:(left+right)/2-width/(2*zoom),y:(top+bottom)/2-height/(2*zoom)},zoom}
|
||||||
|
}
|
||||||
@@ -44,3 +44,21 @@ test("Карта: reduced motion и самостоятельный SVG эксп
|
|||||||
const parsed=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,"image/svg+xml");return {errors:doc.querySelectorAll("parsererror").length,width:doc.documentElement.getAttribute("width")}},source)
|
const parsed=await page.evaluate(source=>{const doc=new DOMParser().parseFromString(source,"image/svg+xml");return {errors:doc.querySelectorAll("parsererror").length,width:doc.documentElement.getAttribute("width")}},source)
|
||||||
expect(parsed.errors).toBe(0);expect(parsed.width).toBe("1240")
|
expect(parsed.errors).toBe(0);expect(parsed.width).toBe("1240")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("Карта: пропорции камеры, ручной масштаб и обновление без сброса",async({page})=>{
|
||||||
|
await page.setViewportSize({width:390,height:900});await page.goto("/network-map")
|
||||||
|
const map=page.getByRole("group",{name:"Интерактивная карта сети"})
|
||||||
|
await expect(page.getByRole("button",{name:/^Устройство Домашний/})).toBeVisible()
|
||||||
|
await expect.poll(async()=>Number((await map.getAttribute("viewBox"))!.split(" ")[2])).toBeLessThan(1240)
|
||||||
|
const camera=await map.evaluate((element:SVGSVGElement)=>{const rect=element.getBoundingClientRect(),box=element.viewBox.baseVal;return {ratio:box.width/box.height,screenRatio:rect.width/rect.height}})
|
||||||
|
expect(camera.ratio).toBeCloseTo(camera.screenRatio,3)
|
||||||
|
await page.getByRole("button",{name:"Увеличить карту",exact:true}).click()
|
||||||
|
const manual=await map.getAttribute("viewBox")
|
||||||
|
await page.getByRole("button",{name:"Обновить",exact:true}).click()
|
||||||
|
await expect(page.getByRole("button",{name:"Обновить",exact:true})).toBeEnabled();await expect(map).toHaveAttribute("viewBox",manual!)
|
||||||
|
await page.getByRole("button",{name:"Вписать карту",exact:true}).click()
|
||||||
|
expect(await map.getAttribute("viewBox")).not.toBe(manual)
|
||||||
|
await page.getByRole("combobox",{name:"Устройство на карте"}).selectOption("1")
|
||||||
|
const wan=page.getByRole("button",{name:"WAN WAN",exact:true});await wan.focus();await wan.press("Enter")
|
||||||
|
await expect(page).toHaveURL(/wan=0/);await expect(wan).toHaveAttribute("aria-pressed","true")
|
||||||
|
})
|
||||||