diff --git a/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css b/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css index 1de65eef..44c8b2ea 100644 --- a/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css +++ b/frontend/src/app/site-finder/analysis/[cad]/v2/v2.module.css @@ -1891,3 +1891,167 @@ transform: translateY(-3px); } } + +/* ===================================================================== + * MAP — per-theme title + resource legend overlay + * (matches prototype `ptica-v2/index.html` `.map-head` + `.legend .lg-res`) + * ===================================================================== */ + +/* Per-theme map title — both spans rendered, CSS shows one. Mirrors the + prototype `.tnum-d` / `.tnum-l` toggle. */ +.mapTitleDark, +.mapTitleLight { + display: none; +} +.v2Root[data-theme="dark"] .mapTitleDark { + display: inline; +} +.v2Root[data-theme="light"] .mapTitleLight { + display: inline; +} + +/* Resource legend (top-left, under the map title) — compact 5-row panel. */ +.mapLegend { + position: absolute; + left: 11px; + top: 34px; + z-index: 6; + min-width: 168px; + max-width: 200px; + background: var(--surface-strong); + border: 1px solid var(--border); + border-radius: var(--radius-xs); + padding: 8px 9px; + backdrop-filter: blur(3px); + pointer-events: none; +} +.mapLegendTitle { + font-family: var(--font-mono); + font-size: 7.5px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-soft); + margin: 0 0 5px; +} +.mapLegendRow { + display: flex; + align-items: center; + gap: 6px; + height: 15px; + font-size: 9px; +} +.mapLegendDot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 auto; + box-shadow: 0 0 5px currentColor; +} +.mapLegendLabel { + font-family: var(--font-mono); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text); + font-size: 8.5px; +} +.mapLegendDist { + margin-left: auto; + font-family: var(--font-mono); + font-size: 8.5px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +.mapLegendDistEmpty { + color: var(--text-soft); + opacity: 0.7; +} + +/* ===================================================================== + * LIGHT THEME — targeted contrast polish + * Tokens stay; just bump the value-side text and the cyan accents that + * appear washed-out on white. Dark theme is untouched. + * ===================================================================== */ + +/* kv-row: value text on white needs darker ink. */ +.v2Root[data-theme="light"] .kv .v { + color: var(--text-strong); +} + +/* Инженерия status-row: distance was --text-soft (#768493 ≈ AA-borderline + at 12px on white); push to --text-muted (#51616f) for solid AA. */ +.v2Root[data-theme="light"] .engRow .st .dm { + color: var(--text-muted); +} +/* Status-dot glow on white reads as a halo — soften it. */ +.v2Root[data-theme="light"] .engRow .nm .sd { + box-shadow: none; +} + +/* Map-legend value column — same readability bump on white. */ +.v2Root[data-theme="light"] .mapLegendDist { + color: var(--text-strong); +} +.v2Root[data-theme="light"] .mapLegendLabel { + color: var(--text-strong); +} +.v2Root[data-theme="light"] .mapLegendDot { + box-shadow: none; +} +/* Map title (light): drop the dark text-shadow that makes thin uppercase + look smudgy on a satellite-light map base. */ +.v2Root[data-theme="light"] .mapHead .ttl { + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.6); + color: var(--text-strong); +} + +/* Cards `Подробнее →` — cyan on white is faint. Swap to the deeper blue. */ +.v2Root[data-theme="light"] .more { + color: var(--accent-blue); +} + +/* Drawer eyebrow (cyan in dark) — use blue on white for AA. */ +.v2Root[data-theme="light"] .drawerH .dhEyebrow { + color: var(--accent-blue); +} + +/* Topbar version-chip: cyan text on a white pill is illegible. */ +.v2Root[data-theme="light"] .ver { + color: var(--accent-blue); + border-color: var(--border-strong); + background: var(--surface); +} +/* Topbar clock — separator-like vertical line between time + date is faint; + strengthen the date line and the chip border for visible structure. */ +.v2Root[data-theme="light"] .clock .date { + color: var(--text-muted); +} + +/* Active tab underline — cyan + glow on white reads as a pale smudge. + Swap to brand blue + drop the glow halo. */ +.v2Root[data-theme="light"] .tabActive::after { + background: var(--accent-blue); + box-shadow: none; +} +/* Rail active marker — same treatment. */ +.v2Root[data-theme="light"] .railItemActive { + color: var(--accent-blue); +} +.v2Root[data-theme="light"] .railItemActive::before { + background: var(--accent-blue); + box-shadow: none; +} + +/* Generic note / drawer note / source — bump from --text-soft to --text-muted + on white so the caveat copy stays readable but still secondary. */ +.v2Root[data-theme="light"] .note, +.v2Root[data-theme="light"] .dnote, +.v2Root[data-theme="light"] .dsource { + color: var(--text-muted); +} + +/* Section separator line inside drawer (`::after` on .dsecT) — barely visible + on white at 10% alpha; strengthen the border-faint reach within drawer. */ +.v2Root[data-theme="light"] .dsecT::after, +.v2Root[data-theme="light"] .drow { + border-color: rgba(40, 70, 95, 0.16); +} diff --git a/frontend/src/components/site-finder/ptica-v2/V2Map.tsx b/frontend/src/components/site-finder/ptica-v2/V2Map.tsx index eeb8c383..3d5d25df 100644 --- a/frontend/src/components/site-finder/ptica-v2/V2Map.tsx +++ b/frontend/src/components/site-finder/ptica-v2/V2Map.tsx @@ -19,6 +19,7 @@ import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; import type { ParcelAnalysis } from "@/types/site-finder"; import { useConnectionPoints } from "@/hooks/useConnectionPoints"; import { useCustomPois } from "@/hooks/useCustomPois"; +import { V2MapLegend } from "@/components/site-finder/ptica-v2/V2MapLegend"; const PticaMapInner = dynamic( () => import("@/components/site-finder/ptica/PticaMapInner"), @@ -49,9 +50,20 @@ export function V2Map({ analysis }: Props) { customPois={customPois} /> + {/* Map title — text differs per theme; only one span is shown at a time + via the [data-theme="..."] selectors in v2.module.css (mirrors the + prototype `.tnum-d` / `.tnum-l` toggle pattern). */}
- Карта участка + + СПУТНИК · СВЕЖИЙ СНИМОК + + + СПУТНИК · СХЕМА ПОДКЛЮЧЕНИЙ +
+ {/* Resource legend overlay (sibling of PticaMapInner, sits on top of the + map via absolute positioning — does not touch the shared inner map). */} + ); } diff --git a/frontend/src/components/site-finder/ptica-v2/V2MapLegend.tsx b/frontend/src/components/site-finder/ptica-v2/V2MapLegend.tsx new file mode 100644 index 00000000..b4a64329 --- /dev/null +++ b/frontend/src/components/site-finder/ptica-v2/V2MapLegend.tsx @@ -0,0 +1,133 @@ +"use client"; + +/** + * V2MapLegend — compact resource legend overlay rendered ON TOP of the cockpit + * map card (prototype `ptica-v2/index.html`, block `.legend > .lg-block.lg-res`). + * + * Five rows — ЭЛЕКТРИЧЕСТВО / ВОДА / КАНАЛИЗАЦИЯ / ТЕПЛОСНАБЖЕНИЕ / ГАЗ — each + * showing the distance to the nearest engineering node + a status-dot (green when + * the узел is within ~1 km, orange when further). Data comes from + * `analysis.utilities.summary[]` whose `subtype` values are the OSM `road_class` + * tags produced by the backend (see `noise_loader._NOISE_QUERIES` + `parcels.py` + * `_nearest()` aliases) — we aggregate them into the 5 prototype buckets. + * + * The legend is a SIBLING of `PticaMapInner` inside `V2Map` (the inner Leaflet + * mount is shared with the старый ptica route and must NOT be touched). All + * styles live in the scoped v2 module so the overlay never leaks into the + * application chrome. + */ + +import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css"; +import type { ParcelAnalysis } from "@/types/site-finder"; + +type ResourceKey = "electric" | "water" | "sewer" | "heat" | "gas"; + +interface ResourceDef { + key: ResourceKey; + label: string; + /** OSM road_class values that count toward this bucket. */ + subtypes: readonly string[]; + /** CSS var for the dot accent color (matches prototype --res-* tokens). */ + color: string; +} + +// Aliases mirror backend `_nearest()` in `backend/app/api/v1/parcels.py`: +// electric ← substation | transformer | power_line +// water ← water_main | water_works | water_tower +// sewer ← sewerage +// heat ← heat_substation +// gas ← gas_pipeline (legacy generic «pipeline» falls under gas) +const RESOURCES: readonly ResourceDef[] = [ + { + key: "electric", + label: "ЭЛЕКТРИЧЕСТВО", + subtypes: ["substation", "transformer", "power_line"], + color: "var(--res-electric)", + }, + { + key: "water", + label: "ВОДА", + subtypes: ["water_main", "water_works", "water_tower"], + color: "var(--res-water)", + }, + { + key: "sewer", + label: "КАНАЛИЗАЦИЯ", + subtypes: ["sewerage"], + color: "var(--res-sewer)", + }, + { + key: "heat", + label: "ТЕПЛОСНАБЖЕНИЕ", + subtypes: ["heat_substation"], + color: "var(--res-heat)", + }, + { + key: "gas", + label: "ГАЗ", + subtypes: ["gas_pipeline", "pipeline"], + color: "var(--res-gas)", + }, +] as const; + +const OK_THRESHOLD_M = 1000; + +function formatDistanceMeters(m: number): string { + // Prototype uses tabular-num, single-line «1 240 м». Round to whole meters. + return `${Math.round(m).toLocaleString("ru-RU")} м`; +} + +function nearestForResource( + summary: ReadonlyArray<{ subtype: string; nearest_m: number }>, + subtypes: readonly string[], +): number | null { + let best: number | null = null; + for (const u of summary) { + if (!subtypes.includes(u.subtype)) continue; + if (best === null || u.nearest_m < best) best = u.nearest_m; + } + return best; +} + +interface Props { + analysis: ParcelAnalysis; +} + +export function V2MapLegend({ analysis }: Props) { + const summary = analysis.utilities?.summary ?? []; + + return ( +
+
ТОЧКИ ПОДКЛЮЧЕНИЯ РЕСУРСОВ
+ {RESOURCES.map((res) => { + const nearest = nearestForResource(summary, res.subtypes); + const hasData = nearest !== null; + const ok = hasData && nearest! <= OK_THRESHOLD_M; + const dotColor = hasData + ? ok + ? "var(--accent-green)" + : "var(--accent-orange)" + : "var(--text-soft)"; + return ( +
+
+ ); + })} +
+ ); +}