Наполнение карты точками подключения с ХАРАКТЕРИСТИКАМИ (свободная мощность) из верифицированных источников (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-комментарий).
190 lines
6.7 KiB
TypeScript
190 lines
6.7 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;
|
||
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;
|
||
}
|