fix(ui): вписывать топологию в область карты

This commit is contained in:
Denozordec committed 2026-10-01 03:18:02 +07:00
1 parent 89e48c2424
commit bc83b8dbbf
13 files changed
+91 -88

No files matched your search

+42 -86
View File
@@ -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",
+1 -1
View File
@@ -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 и пустые состояния; итоговая приёмка карты ещё не завершена.
+9 -1
View File
@@ -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.
+8
View File
@@ -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 и локальная приёмка карты. Реальная сеть не проверена.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 57 KiB

+5
View File
@@ -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)})
+8
View File
@@ -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}
}
+18
View File
@@ -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")
})