feat(site-finder/v2): resource legend overlay + light theme polish
— V2MapLegend overlay над cockpit-картой (электр/вода/канал/тепло/газ) с nearest_m + статус-точка, источник utilities.summary (subtype-buckets зеркалят backend _nearest() aliases; electric расширен power_line, gas включает legacy generic pipeline). — Per-theme заголовок карты: dark «СПУТНИК · СВЕЖИЙ СНИМОК», light «СПУТНИК · СХЕМА ПОДКЛЮЧЕНИЙ» (toggle через CSS [data-theme], без JS). — Точечный light-theme contrast в v2.module.css: kv/eng значения, drawer eyebrow, .more / .ver / активная вкладка с cyan→accent-blue, status-dot halo off на белом. Dark не тронут. Scope: только ptica-v2/* и v2.module.css. PticaMapInner (шарится со старым ptica) не тронут.
This commit is contained in:
parent
ec7a710ca9
commit
c3e091c811
3 changed files with 310 additions and 1 deletions
|
|
@ -1891,3 +1891,167 @@
|
||||||
transform: translateY(-3px);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import styles from "@/app/site-finder/analysis/[cad]/v2/v2.module.css";
|
||||||
import type { ParcelAnalysis } from "@/types/site-finder";
|
import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
|
||||||
import { useCustomPois } from "@/hooks/useCustomPois";
|
import { useCustomPois } from "@/hooks/useCustomPois";
|
||||||
|
import { V2MapLegend } from "@/components/site-finder/ptica-v2/V2MapLegend";
|
||||||
|
|
||||||
const PticaMapInner = dynamic(
|
const PticaMapInner = dynamic(
|
||||||
() => import("@/components/site-finder/ptica/PticaMapInner"),
|
() => import("@/components/site-finder/ptica/PticaMapInner"),
|
||||||
|
|
@ -49,9 +50,20 @@ export function V2Map({ analysis }: Props) {
|
||||||
customPois={customPois}
|
customPois={customPois}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 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). */}
|
||||||
<div className={styles.mapHead}>
|
<div className={styles.mapHead}>
|
||||||
<span className={styles.ttl}>Карта участка</span>
|
<span className={`${styles.ttl} ${styles.mapTitleDark}`}>
|
||||||
|
СПУТНИК · СВЕЖИЙ СНИМОК
|
||||||
|
</span>
|
||||||
|
<span className={`${styles.ttl} ${styles.mapTitleLight}`}>
|
||||||
|
СПУТНИК · СХЕМА ПОДКЛЮЧЕНИЙ
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Resource legend overlay (sibling of PticaMapInner, sits on top of the
|
||||||
|
map via absolute positioning — does not touch the shared inner map). */}
|
||||||
|
<V2MapLegend analysis={analysis} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
frontend/src/components/site-finder/ptica-v2/V2MapLegend.tsx
Normal file
133
frontend/src/components/site-finder/ptica-v2/V2MapLegend.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={styles.mapLegend} aria-label="Подключение ресурсов">
|
||||||
|
<div className={styles.mapLegendTitle}>ТОЧКИ ПОДКЛЮЧЕНИЯ РЕСУРСОВ</div>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={res.key}
|
||||||
|
className={styles.mapLegendRow}
|
||||||
|
style={{ color: res.color }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={styles.mapLegendDot}
|
||||||
|
style={{ background: dotColor, color: dotColor }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className={styles.mapLegendLabel}>{res.label}</span>
|
||||||
|
<span
|
||||||
|
className={`${styles.mapLegendDist} ${hasData ? "" : styles.mapLegendDistEmpty}`}
|
||||||
|
>
|
||||||
|
{hasData ? formatDistanceMeters(nearest!) : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue