259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
/**
|
||
* connection-capacity — общие хелперы для §3 «Сети»: ресурсные резервы (ЦП
|
||
* Россетей + Водоканал). Используются и карточкой «Ресурсные резервы»
|
||
* (Section2NetworksUtilities), и слоем центров питания на карте
|
||
* (PowerConnectionPointsLayer) — чтобы цвета/подписи/приведение чисел были
|
||
* идентичны в обоих местах.
|
||
*/
|
||
|
||
import type {
|
||
GasOutletPoint,
|
||
PowerConnectionPoint,
|
||
WaterCapacityRow,
|
||
} from "@/hooks/useConnectionCapacity";
|
||
|
||
// ── Coercion (Pydantic Decimal → строка) ──────────────────────────────────────
|
||
//
|
||
// NUMERIC-поля БД сериализуются Pydantic v2 как СТРОКИ («12.5»), хотя OpenAPI-тип
|
||
// объявлен number | null. `typeof === "number"`-гварды молча роняют такое поле
|
||
// (см. урок MarketLayers.toFiniteNumber). Приводим руками. Локальная копия по
|
||
// образцу MarketLayers — намеренно, чтобы не тянуть map-модуль в неграфические
|
||
// места.
|
||
export function toFiniteNumber(
|
||
v: number | string | null | undefined,
|
||
): number | null {
|
||
if (v === null || v === undefined) return null;
|
||
const n = typeof v === "string" ? Number(v) : v;
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
// ── Индекс загрузки центра питания (load_index) ────────────────────────────────
|
||
|
||
export type LoadIndex = "open" | "limited" | "closed";
|
||
|
||
export function classifyLoadIndex(
|
||
raw: string | null | undefined,
|
||
): LoadIndex | null {
|
||
switch (raw) {
|
||
case "open":
|
||
case "limited":
|
||
case "closed":
|
||
return raw;
|
||
default:
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// Бейдж статуса ТП по индексу загрузки. Цвета — semantic-палитра карточек §3
|
||
// (совпадает с SEVERITY_COLOR в Pipeline24moBlock; для карты — документированное
|
||
// token-hex исключение popup'ов).
|
||
export const LOAD_INDEX_BADGE: Record<
|
||
LoadIndex,
|
||
{ label: string; bg: string; fg: string; border: string }
|
||
> = {
|
||
open: {
|
||
label: "открыт для ТП",
|
||
bg: "#dcfce7",
|
||
fg: "#15803d",
|
||
border: "#86efac",
|
||
},
|
||
limited: {
|
||
label: "ограничен",
|
||
bg: "#fef9c3",
|
||
fg: "#a16207",
|
||
border: "#fde68a",
|
||
},
|
||
closed: {
|
||
label: "закрыт",
|
||
bg: "#fee2e2",
|
||
fg: "#b91c1c",
|
||
border: "#fca5a5",
|
||
},
|
||
};
|
||
|
||
// Цвет маркера ЦП на карте по индексу загрузки (null → нейтральный серый).
|
||
export const LOAD_INDEX_MARKER_COLOR: Record<LoadIndex, string> = {
|
||
open: "#15803d",
|
||
limited: "#a16207",
|
||
closed: "#b91c1c",
|
||
};
|
||
|
||
export const LOAD_INDEX_NULL_COLOR = "#6b7280";
|
||
|
||
export function loadIndexColor(raw: string | null | undefined): string {
|
||
const idx = classifyLoadIndex(raw);
|
||
return idx ? LOAD_INDEX_MARKER_COLOR[idx] : LOAD_INDEX_NULL_COLOR;
|
||
}
|
||
|
||
export function humanizeLoadIndex(
|
||
raw: string | null | undefined,
|
||
): string | null {
|
||
const idx = classifyLoadIndex(raw);
|
||
return idx ? LOAD_INDEX_BADGE[idx].label : null;
|
||
}
|
||
|
||
// ── Форматирование чисел ───────────────────────────────────────────────────────
|
||
|
||
// МВА с одним знаком после запятой (резерв/мощность ЦП). Приводит строку→число.
|
||
// unit — необязательный override единицы измерения (бэк отдаёт reserve_unit;
|
||
// напр. «кВт» вместо «МВА» для отдельных ЦП). null/undefined → дефолт «МВА».
|
||
export function formatMva(
|
||
v: number | string | null | undefined,
|
||
unit?: string | null,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
const u = unit?.trim() || "МВА";
|
||
return `${n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
})} ${u}`;
|
||
}
|
||
|
||
// тыс. м³/сут (резерв воды/канализации). Может быть ОТРИЦАТЕЛЬНЫМ (дефицит) —
|
||
// знак сохраняем (Unicode-минус даёт toLocaleString).
|
||
export function formatThousandM3Day(
|
||
v: number | string | null | undefined,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
return `${n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
})} тыс. м³/сут`;
|
||
}
|
||
|
||
export function formatMeters(
|
||
v: number | string | null | undefined,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
return `${Math.round(n).toLocaleString("ru-RU")} м`;
|
||
}
|
||
|
||
// тыс. м³/ч (свободная/проектная мощность ГРС). Приводит строку→число (Decimal→str).
|
||
//
|
||
// Честность околонуля: при 1 знаке «0,044» округлился бы в «0,0» и ГРС с
|
||
// НЕнулевой (но крохотной) свободной мощностью выглядела бы закрытой. Для
|
||
// 0 < |v| < 0,05 показываем 2 знака («0,04» / «−0,04»); настоящий 0 — «0,0».
|
||
export function formatThousandM3Hour(
|
||
v: number | string | null | undefined,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
const digits = n !== 0 && Math.abs(n) < 0.05 ? 2 : 1;
|
||
return `${n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: digits,
|
||
maximumFractionDigits: digits,
|
||
})} тыс. м³/ч`;
|
||
}
|
||
|
||
// млн. м³/мес (свободная мощность точки выхода газосети, форма 6 Екатеринбурггаза —
|
||
// ВНИМАНИЕ: единица отличается от ГРС, где тыс. м³/ч). Может быть ОТРИЦАТЕЛЬНОЙ
|
||
// (дефицит со знаком) — знак сохраняем. Три знака после запятой: источник даёт
|
||
// «0.000». Приводит строку→число (Decimal→str).
|
||
export function formatMlnM3Month(
|
||
v: number | string | null | undefined,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
return `${n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 3,
|
||
maximumFractionDigits: 3,
|
||
})} млн м³/мес`;
|
||
}
|
||
|
||
// Гкал/ч (резерв тепловой мощности системы теплоснабжения). Может быть
|
||
// ОТРИЦАТЕЛЬНЫМ (система исчерпана) — знак сохраняем. Приводит строку→число.
|
||
export function formatGcalHour(
|
||
v: number | string | null | undefined,
|
||
): string | null {
|
||
const n = toFiniteNumber(v);
|
||
if (n === null) return null;
|
||
return `${n.toLocaleString("ru-RU", {
|
||
minimumFractionDigits: 1,
|
||
maximumFractionDigits: 1,
|
||
})} Гкал/ч`;
|
||
}
|
||
|
||
// ── Вода: выбор «городской» системы + группировка ──────────────────────────────
|
||
//
|
||
// Городская система ЦСВ/ЦСК выделяется в карточке отдельной строкой: сначала по
|
||
// имени (содержит «ЗФС» — Западная фильтровальная станция ЕКБ), иначе — самая
|
||
// крупная по |резерв| (крупнейшая система города). Остальные сворачиваются.
|
||
|
||
export function pickCitySystem(
|
||
rows: WaterCapacityRow[],
|
||
): WaterCapacityRow | null {
|
||
if (rows.length === 0) return null;
|
||
const byName = rows.find((r) => (r.system_name ?? "").includes("ЗФС"));
|
||
if (byName) return byName;
|
||
// Самая крупная по модулю резерва (city-scale система).
|
||
let best: WaterCapacityRow | null = null;
|
||
let bestAbs = -Infinity;
|
||
for (const r of rows) {
|
||
const n = toFiniteNumber(r.reserve_thousand_m3_day);
|
||
const abs = n === null ? -Infinity : Math.abs(n);
|
||
if (abs > bestAbs) {
|
||
bestAbs = abs;
|
||
best = r;
|
||
}
|
||
}
|
||
return best ?? rows[0];
|
||
}
|
||
|
||
// Множественное число «система» для «+N посёлковых систем».
|
||
export function pluralizeSystems(n: number): string {
|
||
const mod10 = n % 10;
|
||
const mod100 = n % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return `${n} система`;
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20))
|
||
return `${n} системы`;
|
||
return `${n} систем`;
|
||
}
|
||
|
||
// Ближайший ЦП с резервом → строка-summary для карточки. Порядок нужен, чтобы
|
||
// НЕ раскрывать null-поля муторным «нет данных».
|
||
export function powerPointReserveIsNegative(p: PowerConnectionPoint): boolean {
|
||
const n = toFiniteNumber(p.reserve_mva);
|
||
return n !== null && n <= 0;
|
||
}
|
||
|
||
// ── Точки выхода газосети (форма 6 Екатеринбурггаза) ───────────────────────────
|
||
//
|
||
// Свободная мощность точки выхода классифицируется по ЗНАКУ:
|
||
// profit — free_capacity_mln_m3 > 0 (профицит), зелёный;
|
||
// deficit — < 0 (дефицит со знаком), красный;
|
||
// calc — needs_calc=true / null (нужен гидравлический расчёт), жёлтый.
|
||
// Маркеры карты используют hex токенов --success/--danger/--warn (документированное
|
||
// исключение raw-Leaflet попапов, ui-tokens.md), карточка — CSS-переменные напрямую.
|
||
|
||
export type GasOutletState = "profit" | "deficit" | "calc";
|
||
|
||
// Hex-зеркала токенов (--success/--danger/--warn) для CircleMarker fillColor.
|
||
export const GAS_OUTLET_MARKER_COLOR: Record<GasOutletState, string> = {
|
||
profit: "#0A7A3A", // --success
|
||
deficit: "#B3261E", // --danger
|
||
calc: "#9A6700", // --warn
|
||
};
|
||
|
||
export function classifyGasOutlet(p: GasOutletPoint): GasOutletState {
|
||
if (p.needs_calc) return "calc";
|
||
const n = toFiniteNumber(p.free_capacity_mln_m3);
|
||
if (n === null) return "calc";
|
||
return n < 0 ? "deficit" : "profit";
|
||
}
|
||
|
||
export function gasOutletColor(p: GasOutletPoint): string {
|
||
return GAS_OUTLET_MARKER_COLOR[classifyGasOutlet(p)];
|
||
}
|
||
|
||
// Множественное число «точка» для «Точки выхода газа (N)» / счётчиков.
|
||
export function pluralizeOutletPoints(n: number): string {
|
||
const mod10 = n % 10;
|
||
const mod100 = n % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return `${n} точка`;
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20))
|
||
return `${n} точки`;
|
||
return `${n} точек`;
|
||
}
|