--- name: Страна раскрывает сервисы overview: Клик по стране на карте оставляет остальные страны на холсте и раскрывает справа столбец сервисов этой страны (доля от трафика страны). Данные — вложенная агрегация brand×ISO в том же hops-проходе. todos: - id: contract-nested content: countryServiceGroups + mapCountryServiceNodeId в контракте/брендах status: done - id: backend-nested content: Агрегация brand×country в hops, рёбра страна→сервис status: done - id: backend-nested-tests content: Тесты групп US/NL, id cc:us|svc:*, fromId=страна status: done - id: ui-expand content: "Клик по стране: сдвиг колонки, столбец сервисов, панель, mock" status: done isProject: false --- # Раскрытие сервисов страны на карте Эталон холста — [`app/(main)/network-map/page.tsx`](c:/Users/shats/Dev/MikrotikManager-3/app/(main)/network-map/page.tsx) ([Frame](https://reui.io/docs/components/base/frame) не меняем: SVG, не ops-list). UX: **клик по стране → справа узлы сервисов, остальные страны остаются**. ```mermaid flowchart LR dest[internetPeer] --> brand[mapInternetBrand] dest --> geo[ISO country] brand --> nested["group cc:us"] geo --> nested nested --> col["столбец сервисов"] click[клик по стране] --> col ``` Сейчас hops считает бренд и страну **независимо** в одном цикле по `dstAcc` ([`traffic-flow-map-hops.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.ts) ~567–583). Связки «Google внутри US» в API нет — без неё UI может только гадать. ## Поведение на холсте - Режим **Страны**, слой «Назначения» включён: как сейчас (EN → страны). - **Клик по стране**: она selected; справа появляется **второй столбец** сервисов этой страны; остальные страны **остаются**, но приглушаются (`opacity` как у `isVis`). - Рёбра раскрытия: **страна → сервис** (пунктир, те же подписи скорости). EN → страна для выбранной страны подсвечивается. - Повторный клик по той же стране / клик по пустому холсту / смена `destMode` → свернуть столбец. - Клик по другой стране → свернуть предыдущий набор и раскрыть новый. - Клик по сервису: selected = сервис; столбец остаётся; панель — «Сервис в {страна}». Раскладка в [`lib/network-map-layout.ts`](c:/Users/shats/Dev/MikrotikManager-3/lib/network-map-layout.ts): сейчас одна колонка `x = W - MARGIN - SERVICE_COL_W/2`. При раскрытии: - страны сдвигаются влево на `SERVICE_COL_W` (чтобы не налезать на Канаду/Вьетнам с скрина); - сервисы занимают прежний правый `x`, **по вертикали вокруг Y выбранной страны** (новый хелпер `placeCountryServiceNodes(ids, parentPos)`), не общий центр EN. Доля на узле сервиса = **bytes / bytes страны** (не доля окна): иначе у DE 2% все сервисы будут «0%». В панели дополнительно можно показать долю окна. Cap вложенного слоя: тот же `pickMapServices`, но `shareBase` = байты страны, cap **8**, min узлов **4**. «Прочее» внутри страны — `svc:other`. ## Контракт В [`packages/contracts/src/traffic-flow.ts`](c:/Users/shats/Dev/MikrotikManager-3/packages/contracts/src/traffic-flow.ts) optional: ```ts countryServiceGroups: z.array(z.object({ countryId: z.string(), // cc:us services: z.array(flowMapServiceDtoSchema), edges: z.array(flowMapServiceEdgeDtoSchema), // fromId = countryId paths: z.array(flowMapServicePathDtoSchema), // serviceId = nested id })).optional() ``` id сервиса во вложении: `cc:us|svc:google` (`mapCountryServiceNodeId`), чтобы Google в US и NL не смешивались в одном payload. Группы только для стран, прошедших `pickMapServices` стран. ## Бэкенд В том же цикле `dstAcc`: после `svcId` + `country.id` копить `nestedTotals/edges/paths` с ключом `${country.id}|${svcId}`. Рёбра вложенного слоя: `fromId = country.id` (не EN). Пути: как сервисные, `serviceId` = nested id (enId остаётся для подсветки HR→JH→EN). `finalize` по каждой стране из `countriesOut.nodes`. ## Фронт - Состояние `expandedCountryId` (не в sessionStorage). - `visibleNested = groups.find(g => g.countryId === expandedCountryId)`. - `ServiceNode` для вложенных: `destMode="services"` (бренд), подпись label, доля от страны. - Панель страны: текущие KPI + короткий список топ-сервисов (клик = тот же expand/select). - Панель сервиса: бренд + «в {countryName}», доля страны, пути `visibleNested.paths` с `serviceId`. - Mock: `MOCK_COUNTRY_SERVICE_GROUPS` для `cc:us` (Google / Cloudflare / AWS). ## Тесты [`traffic-flow-map-hops.test.ts`](c:/Users/shats/Dev/MikrotikManager-3/backend/src/services/traffic-flow-map-hops.test.ts): ripe US → группа `cc:us` с брендом 8.8.8.8; кейс US+NL — сервисы не пересекают `countryId`; nested `edges.fromId` = `cc:us`, не `svc:*`. Хелпер id — рядом с `mapCountryNodeId` в brands-тесте. ## Вне скоупа - Одновременное раскрытие двух стран. - Географическая карта мира. - Отдельный порог NetFlow для вложенных сервисов (тот же %, но база = страна).