gendesign/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx
Light1YT e285cbe33a
All checks were successful
CI / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m17s
CI / openapi-codegen-check (pull_request) Successful in 2m6s
CI / backend-tests (pull_request) Successful in 14m28s
feat(site-finder): резервы мощности для ТП — электро+вода на карте §3 (#2119 Фаза A)
Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность)
из верифицированных источников (research #2119). Все источники гео-блокируют
не-RU IP — лоадеры выполняются на проде (Celery weekly + ручной docker exec).

Backend:
- Миграция 180: power_supply_centers (ПС 35-220кВ, точки + резерв МВА),
  power_tp_rp_reserves (10.7k ТП/РП 6-10кВ, геокод — Фаза B),
  water_supply_reserves (ЦСВ/ЦСК Водоканала). UNIQUE NULLS NOT DISTINCT на
  nullable-ключах (грабли #140).
- rosseti_wfs_loader: открытый WFS портал-тп.рф (punycode) → 488 ПС Свердл обл,
  индекс загрузки 256184/6/8 → open/limited/closed, voltage из имени.
- rosseti_reserve_loader: раскрытие ПП№24 (xlsx) — ЦП 35-110кВ (строка 8+)
  матчится к WFS-точкам по нормализованному имени; ТП/РП <35кВ (строка 9+),
  кВА-санити (>2.5 МВА для ТП → ÷1000), «н/д»→NULL, SAVEPOINT per-row.
- vodokanal_reserve_loader: DOCX-раскрытие ПП№6 (stdlib parse, vMerge
  forward-fill), отрицательные резервы (город: дефицит) сохраняются со знаком.
- Endpoint GET /{cad}/connection-capacity: ПС в радиусе (ST_DWithin geography)
  + summary + вода за последний период per-system_kind. Celery: вт 04:00/04:30.

Frontend (§3):
- Карточка «Ресурсные резервы»: ближайший ЦП с резервом МВА + статус-бейдж;
  вода/канализация с красным «дефицит» при отрицательном резерве + примечание.
- Слой «Центры питания (резерв)» на карте: цвет по индексу загрузки
  (зелёный/янтарь/красный), попап с напряжением/мощностью/загрузкой/резервом,
  легенда под картой. Числа через toFiniteNumber (Decimal→str coercion).

api-types.ts перегенерён офлайн (+131/-0, аддитивно). 39 тестов зелёные.
Deep-review: ⚠️ MINOR → все 3 замечания исправлены (NULLS NOT DISTINCT,
per-kind MAX(period), settlement-комментарий).
2026-07-02 12:45:55 +05:00

190 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* PowerConnectionPointsLayer — центры питания (ЦП, ПС 35/110 кВ) Россетей со
* свободной мощностью для ТП (#connection-capacity). Отдельный слой поверх карты
* §3 «Сети»: CircleMarker per power_point, цвет по индексу загрузки (load_index),
* чтобы визуально отличаться от оранжевых точек НСПД / инж.сетей OSM.
*
* Источник — обязательное раскрытие сетевых организаций (Россети), а НЕ OSM:
* это разные слои. Координаты ЦП округлены источником (~±100 м) — предупреждаем
* об этом в попапе.
*
* Цвета/подписи/приведение чисел — из общего модуля connection-capacity.ts
* (идентичны карточке «Ресурсные резервы»). Inline token-hex в popup —
* документированное исключение raw-Leaflet попапов (ui-tokens.md).
*/
import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
import type { PowerConnectionPoint } from "@/hooks/useConnectionCapacity";
import {
LOAD_INDEX_BADGE,
classifyLoadIndex,
formatMeters,
formatMva,
loadIndexColor,
powerPointReserveIsNegative,
toFiniteNumber,
} from "@/components/site-finder/connection-capacity";
// ── Point coordinate guard ─────────────────────────────────────────────────────
// lat/lon могут прийти строкой (Decimal→str) — приводим через toFiniteNumber, а
// не typeof-гвардом (иначе валидная точка молча не рисуется).
function pointLatLon(p: PowerConnectionPoint): [number, number] | null {
const lat = toFiniteNumber(p.lat);
const lon = toFiniteNumber(p.lon);
if (lat === null || lon === null) return null;
return [lat, lon];
}
// ── Popup ──────────────────────────────────────────────────────────────────────
function PowerPopup({ point }: { point: PowerConnectionPoint }) {
const idx = classifyLoadIndex(point.load_index);
const badge = idx ? LOAD_INDEX_BADGE[idx] : null;
const color = loadIndexColor(point.load_index);
const voltage = point.voltage_class?.trim() || null;
const installed = formatMva(point.installed_capacity_mva);
const load = formatMva(point.current_load_mva);
const reserve = formatMva(point.reserve_mva);
const reserveNegative = powerPointReserveIsNegative(point);
const distance = formatMeters(point.distance_m);
return (
<Popup>
<div style={{ fontSize: 12, lineHeight: 1.55, minWidth: 200 }}>
<div
style={{
fontWeight: 700,
marginBottom: 4,
display: "flex",
alignItems: "center",
gap: 5,
}}
>
<span
aria-hidden
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: "50%",
background: color,
flexShrink: 0,
}}
/>
Центр питания (Россети)
</div>
<div style={{ marginBottom: 4 }}>
<strong>{point.name}</strong>
</div>
{voltage && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Напряжение: {voltage} кВ
</div>
)}
{installed && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Установленная мощность: {installed}
</div>
)}
{load && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Загрузка: {load}
</div>
)}
{reserve && (
<div
style={{
color: reserveNegative ? "#b91c1c" : "#374151",
marginBottom: 4,
}}
>
Резерв для ТП:{" "}
<strong style={{ color: reserveNegative ? "#b91c1c" : "#111827" }}>
{reserve}
</strong>
</div>
)}
{badge && (
<span
style={{
display: "inline-block",
padding: "1px 8px",
borderRadius: 6,
fontSize: 11,
fontWeight: 600,
color: badge.fg,
background: badge.bg,
border: `1px solid ${badge.border}`,
}}
>
{badge.label}
</span>
)}
{distance && (
<div style={{ marginTop: 4, color: "#374151" }}>
До участка: {distance}
</div>
)}
<div
style={{
marginTop: 4,
fontSize: 11,
color: "#9ca3af",
borderTop: "1px solid #f3f4f6",
paddingTop: 4,
}}
>
Россети{point.reserve_asof ? ` · срез ${point.reserve_asof}` : ""}
</div>
<div style={{ fontSize: 11, color: "#9ca3af" }}>
Координаты ЦП ~±100 м (округление источника)
</div>
</div>
</Popup>
);
}
// ── Layer ──────────────────────────────────────────────────────────────────────
interface Props {
points: PowerConnectionPoint[];
}
export function PowerConnectionPointsLayer({ points }: Props) {
return (
<LayerGroup>
{points.map((point, idx) => {
const latLon = pointLatLon(point);
if (!latLon) return null;
const color = loadIndexColor(point.load_index);
return (
<CircleMarker
key={`power-cp-${point.name}-${idx}`}
center={latLon}
radius={9}
pathOptions={{
color: "#ffffff",
weight: 2,
fillColor: color,
fillOpacity: 0.85,
}}
>
<PowerPopup point={point} />
</CircleMarker>
);
})}
</LayerGroup>
);
}
// Число рисуемых точек (с валидными координатами) — для легенды/счётчика.
export function countMappablePowerPoints(
points: PowerConnectionPoint[] | undefined,
): number {
if (!points) return 0;
return points.filter((p) => pointLatLon(p) !== null).length;
}