gendesign/frontend/src/components/site-finder/PowerConnectionPointsLayer.tsx
bot-backend e501dedf78
All checks were successful
Deploy / build-frontend (push) Successful in 4m38s
Deploy / deploy (push) Successful in 1m37s
Deploy / build-backend (push) Successful in 2m48s
Deploy / build-worker (push) Successful in 3m59s
Deploy / changes (push) Successful in 9s
feat(site-finder): ЕЭСК-резервы городских ПС/ТП + тепло из ФГИС ФАС (#2119 B2) (#2165)
2026-07-02 16:44:57 +00:00

204 lines
7.5 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;
// reserve_unit — единица ТОЛЬКО для строки резерва: у ЕЭСК установленная
// мощность в файле раскрытия дана в МВА, а свободная — в МВт (разные строки
// источника), поэтому unit НЕ распространяем на установленную/загрузку.
const installed = formatMva(point.installed_capacity_mva);
const load = formatMva(point.current_load_mva);
const reserve = formatMva(point.reserve_mva, point.reserve_unit);
const reserveNegative = powerPointReserveIsNegative(point);
const distance = formatMeters(point.distance_m);
const district = point.district?.trim() || null;
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>
{district && (
<div style={{ color: "#374151", marginBottom: 2 }}>
Район: {district}
</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>
)}
{point.reserve_note && (
<div style={{ marginTop: 4, fontSize: 11, color: "#6b7280" }}>
{point.reserve_note}
</div>
)}
{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;
}