fix(site-finder): P3 a11y/UI batch from audit #1871 #1878

Merged
bot-backend merged 1 commit from audit/1871-p3-uiux into main 2026-06-23 07:55:25 +00:00
8 changed files with 166 additions and 33 deletions

View file

@ -119,6 +119,18 @@ export function AnalysisPageContent({ cad }: Props) {
{/* Breadcrumb */} {/* Breadcrumb */}
<Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} /> <Breadcrumb cad={cad} districtName={districtName} areaStr={areaStr} />
<h1
style={{
fontSize: 22,
fontWeight: 600,
margin: "0 0 12px",
color: "var(--fg-primary)",
}}
>
Анализ участка {cad}
{districtName && districtName !== "—" ? ` · ${districtName}` : ""}
</h1>
{/* Controls row — horizon selector drives the analyze + forecast queries */} {/* Controls row — horizon selector drives the analyze + forecast queries */}
<div <div
style={{ style={{

View file

@ -1,5 +1,15 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import type { ComponentType } from "react";
import {
AlertOctagon,
AlertTriangle,
CheckCircle2,
Clock,
HelpCircle,
XCircle,
type LucideProps,
} from "lucide-react";
import type { GateVerdict, GateVerdictLabel } from "@/types/site-finder"; import type { GateVerdict, GateVerdictLabel } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel"; import { SectionLabel } from "@/components/ui/SectionLabel";
@ -10,23 +20,28 @@ interface GateVerdictBannerProps {
interface VerdictColor { interface VerdictColor {
bg: string; bg: string;
border: string; border: string;
icon: string; Icon: ComponentType<LucideProps>;
iconBg: string; iconBg: string;
} }
const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = { const COLOR_BY_LABEL: Record<GateVerdictLabel, VerdictColor> = {
Можно: { bg: "#ecfdf5", border: "#34d399", icon: "✅", iconBg: "#10b981" }, Можно: {
Нельзя: { bg: "#fef2f2", border: "#f87171", icon: "🚫", iconBg: "#ef4444" }, bg: "#ecfdf5",
border: "#34d399",
Icon: CheckCircle2,
iconBg: "#10b981",
},
Нельзя: { bg: "#fef2f2", border: "#f87171", Icon: XCircle, iconBg: "#ef4444" },
"С ограничениями": { "С ограничениями": {
bg: "#fffbeb", bg: "#fffbeb",
border: "#fbbf24", border: "#fbbf24",
icon: "⚠️", Icon: AlertTriangle,
iconBg: "#f59e0b", iconBg: "#f59e0b",
}, },
"Нужна проверка": { "Нужна проверка": {
bg: "#f9fafb", bg: "#f9fafb",
border: "#9ca3af", border: "#9ca3af",
icon: "❓", Icon: HelpCircle,
iconBg: "#6b7280", iconBg: "#6b7280",
}, },
}; };
@ -55,14 +70,32 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
if (!verdict) return null; if (!verdict) return null;
const color = COLOR_BY_LABEL[verdict.verdict_label]; const color = COLOR_BY_LABEL[verdict.verdict_label];
const Icon = color.Icon;
const hasDetails = verdict.blockers.length > 0 || verdict.warnings.length > 0; const hasDetails = verdict.blockers.length > 0 || verdict.warnings.length > 0;
const iconSx = { flexShrink: 0 } as const;
const sourceHint = const sourceHint =
verdict.source === "nspd_dump_partial" verdict.source === "nspd_dump_partial" ? (
? "⏱ устарели" <>
: verdict.source === "no_data" <Clock size={14} strokeWidth={1.5} aria-hidden style={iconSx} />
? "❓ нет данных" устарели
: "🟢 свежие"; </>
) : verdict.source === "no_data" ? (
<>
<HelpCircle size={14} strokeWidth={1.5} aria-hidden style={iconSx} />
нет данных
</>
) : (
<>
<CheckCircle2
size={14}
strokeWidth={1.5}
aria-hidden
style={{ ...iconSx, color: "var(--success, #0A7A3A)" }}
/>
свежие
</>
);
const summaryParts: string[] = []; const summaryParts: string[] = [];
if (verdict.blockers.length > 0) if (verdict.blockers.length > 0)
@ -94,11 +127,10 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontSize: 20,
flexShrink: 0, flexShrink: 0,
}} }}
> >
{color.icon} <Icon size={20} strokeWidth={1.5} aria-hidden />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 18, fontWeight: 700, color: "#111827" }}> <div style={{ fontSize: 18, fontWeight: 700, color: "#111827" }}>
@ -107,7 +139,15 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
<div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}> <div style={{ fontSize: 12, color: "#6b7280", marginTop: 2 }}>
{summaryText} {summaryText}
{" · "} {" · "}
<span title={SOURCE_LABEL[verdict.source] ?? verdict.source}> <span
title={SOURCE_LABEL[verdict.source] ?? verdict.source}
style={{
display: "inline-flex",
alignItems: "center",
gap: 4,
verticalAlign: "middle",
}}
>
{sourceHint} {sourceHint}
</span> </span>
</div> </div>
@ -142,7 +182,23 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
> >
{verdict.blockers.length > 0 && ( {verdict.blockers.length > 0 && (
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<SectionLabel>🚫 Блокеры</SectionLabel> <SectionLabel>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<AlertOctagon
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "var(--danger, #B3261E)" }}
/>
Блокеры
</span>
</SectionLabel>
<ul <ul
style={{ style={{
marginTop: 4, marginTop: 4,
@ -161,7 +217,23 @@ export function GateVerdictBanner({ verdict }: GateVerdictBannerProps) {
)} )}
{verdict.warnings.length > 0 && ( {verdict.warnings.length > 0 && (
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<SectionLabel> Предупреждения</SectionLabel> <SectionLabel>
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
}}
>
<AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "var(--warn, #9A6700)" }}
/>
Предупреждения
</span>
</SectionLabel>
<ul <ul
style={{ style={{
marginTop: 4, marginTop: 4,

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { AlertTriangle, Snowflake } from "lucide-react";
import type { GeotechRisk } from "@/types/site-finder"; import type { GeotechRisk } from "@/types/site-finder";
interface Props { interface Props {
@ -93,7 +94,12 @@ export function GeotechRiskBlock({ risk }: Props) {
color: "#374151", color: "#374151",
}} }}
> >
<span>🟡</span> <Snowflake
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "#92400e" }}
/>
<span>Вечная мерзлота особые фундаменты</span> <span>Вечная мерзлота особые фундаменты</span>
</div> </div>
)} )}
@ -114,7 +120,12 @@ export function GeotechRiskBlock({ risk }: Props) {
gap: 6, gap: 6,
}} }}
> >
<span></span> <AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span> <span>
{risk.industrial_within_500m} промзон(ы) в &lt;500м (риск {risk.industrial_within_500m} промзон(ы) в &lt;500м (риск
загрязнения почв) загрязнения почв)

View file

@ -1,16 +1,21 @@
"use client"; "use client";
import type { ComponentType } from "react";
import { AlertTriangle, Droplet, Waves, type LucideProps } from "lucide-react";
import type { Hydrology, HydrologyNearest } from "@/types/site-finder"; import type { Hydrology, HydrologyNearest } from "@/types/site-finder";
interface Props { interface Props {
hydrology: Hydrology; hydrology: Hydrology;
} }
const SUBTYPE_ICON: Record<string, string> = { // Decorative water glyph per subtype — the text label already names the body
river: "🏞", // (река / ручей / озеро / канал), so the icon is aria-hidden. Lucide has no
stream: "💦", // distinct lake/canal glyph; flowing bodies use Waves, the rest use Droplet.
lake_or_pond: "🪷", const SUBTYPE_ICON: Record<string, ComponentType<LucideProps>> = {
canal: "🚣", river: Waves,
stream: Droplet,
lake_or_pond: Waves,
canal: Waves,
}; };
function subtypeLabel(subtype: HydrologyNearest["subtype"]): string { function subtypeLabel(subtype: HydrologyNearest["subtype"]): string {
@ -63,7 +68,12 @@ export function HydrologyBlock({ hydrology }: Props) {
gap: 6, gap: 6,
}} }}
> >
<span></span> <AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>Возможная пойма река/канал в &lt;200м</span> <span>Возможная пойма река/канал в &lt;200м</span>
</div> </div>
)} )}
@ -76,7 +86,7 @@ export function HydrologyBlock({ hydrology }: Props) {
) : ( ) : (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}> <div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{top5.map((item, i) => { {top5.map((item, i) => {
const icon = SUBTYPE_ICON[item.subtype ?? ""] ?? "💧"; const Icon = SUBTYPE_ICON[item.subtype ?? ""] ?? Droplet;
const label = subtypeLabel(item.subtype); const label = subtypeLabel(item.subtype);
const name = item.name ?? "б/н"; const name = item.name ?? "б/н";
return ( return (
@ -90,7 +100,12 @@ export function HydrologyBlock({ hydrology }: Props) {
color: "#374151", color: "#374151",
}} }}
> >
<span>{icon}</span> <Icon
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0, color: "#0369a1" }}
/>
<span> <span>
{name} {label} {Math.round(item.distance_m)} м {name} {label} {Math.round(item.distance_m)} м
</span> </span>

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { AlertTriangle } from "lucide-react";
import type { FeatureCollection } from "geojson"; import type { FeatureCollection } from "geojson";
import type { ParcelAnalysis } from "@/types/site-finder"; import type { ParcelAnalysis } from "@/types/site-finder";
import { SectionLabel } from "@/components/ui/SectionLabel"; import { SectionLabel } from "@/components/ui/SectionLabel";
@ -185,13 +186,24 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
{data.utilities.power_line_охранная_зона_flag && ( {data.utilities.power_line_охранная_зона_flag && (
<div <div
style={{ style={{
display: "flex",
alignItems: "center",
gap: 6,
fontSize: 12, fontSize: 12,
color: "#b3261e", color: "#b3261e",
fontWeight: 600, fontWeight: 600,
marginTop: 4, marginTop: 4,
}} }}
> >
Охранная зона ЛЭП капитальное строительство запрещено <AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>
Охранная зона ЛЭП капитальное строительство запрещено
</span>
</div> </div>
)} )}
</div> </div>
@ -212,7 +224,12 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
gap: 8, gap: 8,
}} }}
> >
<span style={{ fontSize: 16 }}></span> <AlertTriangle
size={16}
strokeWidth={1.5}
aria-hidden
style={{ flexShrink: 0 }}
/>
<span>Трамвай в {nearestTram} м возможный источник шума</span> <span>Трамвай в {nearestTram} м возможный источник шума</span>
</div> </div>
)} )}

View file

@ -61,11 +61,15 @@ const VIZ: Record<ScenarioKey, string> = {
// Single-line fallback colour (ui-tokens: одиночная линия — #374151). // Single-line fallback colour (ui-tokens: одиночная линия — #374151).
const SINGLE_LINE = "#374151"; const SINGLE_LINE = "#374151";
// Prediction / forecast (dashed) — --prediction-line. // Chrome colours below are intentionally raw hex, NOT var(--token), for the same
const PREDICTION_LINE = "#0EA5E9"; // reason as VIZ above: ChartShell renders via echarts-for-react with the default
// Axis chrome. // CANVAS renderer (no opts.renderer:"svg"), and the canvas 2D context does not
const FG_SECONDARY = "#5B6066"; // resolve CSS custom properties — `var(--x)` would paint as transparent/black.
const BORDER_STRONG = "#D1D5DB"; // Values mirror ui-tokens.md exactly: --prediction-line, --fg-secondary,
// --border-strong. Keep in sync with ui-tokens.md by hand.
const PREDICTION_LINE = "#0EA5E9"; // --prediction-line
const FG_SECONDARY = "#5B6066"; // --fg-secondary
const BORDER_STRONG = "#D1D5DB"; // --border-strong
// ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ── // ── Tooltip row type (echarts-for-react gives loose params; narrow ourselves) ──
interface TooltipParam { interface TooltipParam {

View file

@ -804,7 +804,8 @@ export function Section2NetworksUtilities({ cad }: Props) {
/> />
<span> <span>
Участок находится в охранной зоне ЛЭП 35 кВ. Капитальное Участок находится в охранной зоне ЛЭП 35 кВ. Капитальное
строительство в ОЗ запрещено (СП 36.13330, ЗОУИТ тип 5). строительство в охранной зоне (ОЗ) запрещено (СП 36.13330). Участок
в зоне с особыми условиями использования территорий (ЗОУИТ), тип 5.
</span> </span>
</div> </div>
)} )}

View file

@ -219,6 +219,7 @@ export function Section4Estimate({ cad }: Props) {
}} }}
> >
<div <div
title="Зоны с особыми условиями использования территорий — охранные зоны инженерной инфраструктуры (ЛЭП, газопровод и т.д.)"
style={{ style={{
fontSize: 12, fontSize: 12,
fontWeight: 600, fontWeight: 600,