204 lines
7.5 KiB
TypeScript
204 lines
7.5 KiB
TypeScript
"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;
|
||
}
|