fix(ui): вписывать топологию в область карты
No files matched your search
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useRef, useEffect, useMemo, useCallback, Suspense } from "react"
|
||||
import {fitNetworkMapCamera} from "@/lib/network-map-camera"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import {readNetworkMapUrl,patchNetworkMapUrl,type NetworkMapFilter} from "@/lib/network-map-url-state"
|
||||
import {Alert,AlertTitle,AlertDescription} from "@/components/ui/alert"
|
||||
@@ -441,9 +442,9 @@ function latencyColor(ms: number | null) {
|
||||
if (ms > 60) return "var(--status-degraded)"
|
||||
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("-")
|
||||
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 {
|
||||
@@ -646,7 +647,7 @@ function SvgTooltip({ n }: { n: Server & { x: number; y: number } }) {
|
||||
const oy = n.y - 32
|
||||
const lines = [
|
||||
n.name,
|
||||
`${TYPE_LABELS[n.type]} · ${n.site}`,
|
||||
`${TYPE_LABELS[n.type]} · ${n.site || n.country?.toUpperCase() || "—"}`,
|
||||
n.latency != null ? `${n.latency} мс` : "Офлайн",
|
||||
n.model ?? "",
|
||||
].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 }
|
||||
labelScale?: number
|
||||
isSel: boolean; isVis: boolean; isDragged: boolean
|
||||
/** Не дублировать «N мс» из каталога, если на карте уже показаны метрики GRE (оранжевые числа в круге). */
|
||||
hideCatalogLatency?: boolean
|
||||
@@ -749,13 +751,13 @@ function ServerNode({ n, isSel, isVis, isDragged, hideCatalogLatency, onClick, o
|
||||
lineHeight: 1.25,
|
||||
}}
|
||||
>
|
||||
{n.site}
|
||||
{n.site || n.country?.toUpperCase() || "—"}
|
||||
</span>
|
||||
</div>
|
||||
</foreignObject>
|
||||
) : (
|
||||
<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 && (
|
||||
<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>
|
||||
)}
|
||||
{(n.name && (
|
||||
<text textAnchor="middle" y={hideCatalogLatency ? 15 : ts.r + 18} fontSize="8.5" fill="var(--muted-foreground)"
|
||||
fontFamily="ui-monospace,monospace">{shortName(n.name)}</text>
|
||||
<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,labelScale>1?12:24)}</text>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
@@ -1005,7 +1007,8 @@ function ContextMenu({ menu, onClose }: { menu: CtxMenu; onClose: () => void })
|
||||
|
||||
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
|
||||
nodes: (Server & { x: number; y: number })[]
|
||||
greEdges: GreMapEdge[]
|
||||
@@ -1017,7 +1020,7 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
|
||||
onPan: (x: number, y: number) => void
|
||||
}) {
|
||||
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>) {
|
||||
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" />
|
||||
))}
|
||||
{/* 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" />
|
||||
</svg>
|
||||
<button type="button"
|
||||
@@ -1290,6 +1293,9 @@ function NetworkMapContent() {
|
||||
// ── View state ──────────────────────────────────────────────────────────────
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 })
|
||||
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)
|
||||
|
||||
// ── Interaction ─────────────────────────────────────────────────────────────
|
||||
@@ -1680,6 +1686,9 @@ function NetworkMapContent() {
|
||||
|
||||
// ── Refs ─────────────────────────────────────────────────────────────────────
|
||||
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
|
||||
const dragRef = useRef<{
|
||||
@@ -1742,14 +1751,14 @@ function NetworkMapContent() {
|
||||
const p = panRef.current
|
||||
const rect = svg.getBoundingClientRect()
|
||||
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
|
||||
const step = e.ctrlKey ? 1.06 : 1.14
|
||||
const factor = e.deltaY < 0 ? step : 1 / step
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * factor))
|
||||
setPan({
|
||||
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)
|
||||
}
|
||||
@@ -1762,19 +1771,18 @@ function NetworkMapContent() {
|
||||
const newZoom = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * factor))
|
||||
setPan(p => ({
|
||||
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)
|
||||
},[zoom])
|
||||
},[zoom,viewportHeight])
|
||||
|
||||
const fitView=useCallback(() => {
|
||||
setPan({ x: 0, y: 0 })
|
||||
setZoom(1)
|
||||
},[])
|
||||
const {pan:{x:fitX,y:fitY},zoom:fitZoom}=fitNetworkMapCamera([...Object.values(nodePosById),...Object.values(effectiveSatPos).flat(),...Object.values(servicePosById),...Object.values(nestedPosById)],W,viewportHeight)
|
||||
function fitView(){setPan({x:fitX,y:fitY});setZoom(fitZoom)}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1793,13 +1801,13 @@ function NetworkMapContent() {
|
||||
}
|
||||
if (e.key === "=" || e.key === "+") applyZoomCenter(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() === "p") setShowPingBadges(v => !v)
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [applyZoomCenter,fitView,setSelected,updateUrl,ctxMenu,showLayers])
|
||||
}, [applyZoomCenter,fitX,fitY,fitZoom,setSelected,updateUrl,ctxMenu,showLayers])
|
||||
|
||||
// ── Drag ─────────────────────────────────────────────────────────────────
|
||||
function onSvgMouseDown(e: React.MouseEvent<SVGSVGElement>) {
|
||||
@@ -1816,7 +1824,7 @@ function NetworkMapContent() {
|
||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) nd.moved = true
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
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 ny = nd.origY + svgDy
|
||||
|
||||
@@ -1841,7 +1849,7 @@ function NetworkMapContent() {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
setPan({
|
||||
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">
|
||||
|
||||
{/* ── 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
|
||||
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" }}
|
||||
onMouseDown={e => { e.preventDefault(); onSvgMouseDown(e) }}
|
||||
onMouseMove={onSvgMouseMove}
|
||||
@@ -2449,7 +2461,7 @@ function NetworkMapContent() {
|
||||
|
||||
{/* ── Server nodes ── */}
|
||||
{nodes.map(n => (
|
||||
<ServerNode
|
||||
<ServerNode labelScale={W/(canvasWidth*zoom)}
|
||||
key={n.id} n={n}
|
||||
isSel={selected?.id === n.id}
|
||||
isVis={isVisible(n)}
|
||||
@@ -2708,63 +2720,6 @@ function NetworkMapContent() {
|
||||
<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) ── */}
|
||||
{showHints && (
|
||||
<g transform={`translate(${pan.x + W / zoom - 14}, ${pan.y + H / zoom - 14})`}>
|
||||
@@ -2800,7 +2755,7 @@ function NetworkMapContent() {
|
||||
|
||||
{/* ── Minimap ── */}
|
||||
{showMinimap && (
|
||||
<Minimap
|
||||
<Minimap viewportHeight={viewportHeight}
|
||||
pan={pan} zoom={zoom}
|
||||
nodes={nodes}
|
||||
greEdges={greEdges}
|
||||
@@ -3308,7 +3263,8 @@ function NetworkMapContent() {
|
||||
const isActive = myEdges.some(e => e.active)
|
||||
const isSel = selWanIdx === wIdx
|
||||
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)}
|
||||
className={cn(
|
||||
"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 / [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; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 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Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 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)
|
||||
|
||||
- Установленный 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 явно подписан.
|
||||
- 35 основных + 12 source E2E, TypeScript и lint прошли; ReUI Frame checklist прочитан. Первые регрессионные тесты выбирали скрытые option после добавления селектора: селекторы ограничены SVG, повтор чистый. 6 снимков проверены визуально.
|
||||
- Далее 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)
|
||||
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")
|
||||
})
|
||||