feat(site-finder): блок «Сети рядом с участком» в §3 (Фаза C2-UI) (#2282)
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m22s
Deploy / deploy (push) Successful in 1m17s

This commit is contained in:
bot-backend 2026-07-03 08:23:59 +00:00
parent 7c12812f81
commit 8a42fba836
3 changed files with 172 additions and 1 deletions

View file

@ -15,7 +15,7 @@
* бэка не отработали, показываем нейтральный «загружаются», а не «нет данных».
*/
import { Info, Zap, Droplets, Flame, Thermometer } from "lucide-react";
import { Info, Zap, Droplets, Flame, Thermometer, Route } from "lucide-react";
import type {
ConnectionCapacityResponse,
@ -23,6 +23,7 @@ import type {
GasGrsRow,
HeatCapacityBlock,
HeatSystemRow,
NearbyNetworkZone,
WaterCapacityRow,
} from "@/hooks/useConnectionCapacity";
import { CONNECTION_CAPACITY_RADIUS_M } from "@/hooks/useConnectionCapacity";
@ -34,9 +35,13 @@ import {
formatMva,
formatThousandM3Day,
formatThousandM3Hour,
formatZoneDistance,
humanizeLoadIndex,
humanizeNetKind,
isThermalNear,
pickCitySystem,
pluralizeSystems,
sortNearbyNetworkZones,
toFiniteNumber,
} from "@/components/site-finder/connection-capacity";
@ -494,6 +499,100 @@ function HeatBlock({ heat }: { heat: HeatCapacityBlock | null | undefined }) {
);
}
// ── Сети рядом с участком (охранные зоны, #2119 C2) ────────────────────────────
//
// Где ФИЗИЧЕСКИ проходит сеть (охранный коридор в радиусе 300 м) — позитив-разрез:
// теплотрасса в 24 м → дешёвая врезка. Порядок уже по (distance_m, net_kind) с
// бэка; thermal ≤50 м выводим первым и подсвечиваем success-тоном.
// Маленький нейтральный чип net_kind (тепло/электро/газ/связь/не уточнено).
function NetKindChip({ netKind }: { netKind: string }) {
return (
<span
style={{
display: "inline-block",
padding: "0 6px",
borderRadius: 6,
fontSize: 11,
fontWeight: 500,
lineHeight: "18px",
color: "var(--fg-secondary)",
background: "var(--bg-card-alt)",
border: "1px solid var(--border-card)",
whiteSpace: "nowrap",
}}
>
{humanizeNetKind(netKind)}
</span>
);
}
function NearbyNetworkZoneLine({ zone }: { zone: NearbyNetworkZone }) {
const near = isThermalNear(zone);
const distance = formatZoneDistance(zone.distance_m);
return (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 8,
flexWrap: "wrap",
}}
>
<NetKindChip netKind={zone.net_kind} />
<span
style={{
fontSize: 13,
color: near ? "var(--success)" : "var(--fg-primary)",
fontWeight: near ? 600 : 400,
}}
>
{zone.human_label}
</span>
<StatusBadge
label={distance}
bg={near ? "var(--success-soft)" : "var(--bg-card-alt)"}
fg={near ? "var(--success)" : "var(--fg-secondary)"}
border={near ? undefined : "var(--border-card)"}
/>
</div>
);
}
function NearbyNetworkZonesBlock({ zones }: { zones: NearbyNetworkZone[] }) {
// Пустой массив → блок не рендерится вовсе (не «нет данных»).
if (zones.length === 0) return null;
const ordered = sortNearbyNetworkZones(zones);
return (
<div>
<div style={SUB_HEADER}>
<Route size={14} strokeWidth={1.5} aria-hidden="true" />
Сети рядом с участком
</div>
<div
style={{
fontSize: 12,
color: "var(--fg-secondary)",
marginBottom: 8,
}}
>
По охранным зонам где физически проходит сеть.
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{ordered.map((zone, i) => (
<NearbyNetworkZoneLine
key={`zone-${zone.net_kind}-${zone.distance_m}-${i}`}
zone={zone}
/>
))}
</div>
</div>
);
}
// ── Card ───────────────────────────────────────────────────────────────────────
const SUB_HEADER: React.CSSProperties = {
@ -573,6 +672,9 @@ export function ResourceReservesCard({ data }: Props) {
<WaterBlock rows={data.water} />
</div>
{/* Сети рядом (охранные зоны) — рендерится только при наличии зон */}
<NearbyNetworkZonesBlock zones={data.nearby_network_zones} />
{/* Caveat */}
<div
style={{

View file

@ -8,6 +8,7 @@
import type {
GasOutletPoint,
NearbyNetworkZone,
PowerConnectionPoint,
WaterCapacityRow,
} from "@/hooks/useConnectionCapacity";
@ -257,3 +258,70 @@ export function pluralizeOutletPoints(n: number): string {
return `${n} точки`;
return `${n} точек`;
}
// ── Сети рядом с участком (охранные зоны cad_zouit, #2119 C2) ───────────────────
//
// nearby_network_zones — где ФИЗИЧЕСКИ проходит сеть (охранный коридор) в радиусе
// 300 м. Тепловая зона с малой дистанцией = дешёвая врезка → главный позитив-сигнал
// §3. net_kind — Literal бэка (сериализуется как string), нормализуем к known-набору.
export type NetKind =
"thermal" | "electricity" | "gas" | "communication" | "general";
// Русская метка net_kind (маленький нейтральный чип рядом с human_label).
const NET_KIND_LABEL: Record<NetKind, string> = {
thermal: "тепло",
electricity: "электро",
gas: "газ",
communication: "связь",
general: "не уточнено",
};
export function classifyNetKind(raw: string | null | undefined): NetKind {
switch (raw) {
case "thermal":
case "electricity":
case "gas":
case "communication":
return raw;
default:
// Бэк отдаёт "general" для неклассифицированных; неизвестное → тоже general.
return "general";
}
}
export function humanizeNetKind(raw: string | null | undefined): string {
return NET_KIND_LABEL[classifyNetKind(raw)];
}
// Порог «тепло рядом» — thermal с distance_m ≤ этого выводим первым и подсвечиваем
// позитивным тоном (дешёвая врезка в теплотрассу). Согласован с C2-логикой §3.
export const THERMAL_NEAR_M = 50;
export function isThermalNear(z: NearbyNetworkZone): boolean {
return (
classifyNetKind(z.net_kind) === "thermal" && z.distance_m <= THERMAL_NEAR_M
);
}
// Дистанция зоны → бейдж. distance_m=0 = зона пересекает участок («по участку»).
export function formatZoneDistance(distanceM: number): string {
if (distanceM <= 0) return "по участку";
return `в ${Math.round(distanceM).toLocaleString("ru-RU")} м`;
}
// Сортировка для отображения: сначала thermal-near (главный сигнал), затем — исходный
// порядок бэка (уже (distance_m, net_kind)). Stable — сохраняет тай-брейки сервера.
export function sortNearbyNetworkZones(
zones: readonly NearbyNetworkZone[],
): NearbyNetworkZone[] {
return zones
.map((zone, i) => ({ zone, i }))
.sort((a, b) => {
const aNear = isThermalNear(a.zone) ? 0 : 1;
const bNear = isThermalNear(b.zone) ? 0 : 1;
if (aNear !== bNear) return aNear - bNear;
return a.i - b.i;
})
.map((x) => x.zone);
}

View file

@ -18,6 +18,7 @@ export type GasGrsRow = components["schemas"]["GasGrsRow"];
export type GasOutletPoint = components["schemas"]["GasOutletPoint"];
export type HeatCapacityBlock = components["schemas"]["HeatCapacityBlock"];
export type HeatSystemRow = components["schemas"]["HeatSystemRow"];
export type NearbyNetworkZone = components["schemas"]["NearbyNetworkZone"];
/**
* Радиус поиска центров питания (ЦП, ПС 35/110 кВ) вокруг участка. Дефолт 3 км