gendesign/frontend/src/components/site-finder/analysis/PoiList2Gis.tsx
bot-backend b534579e1b
Some checks failed
CI / changes (pull_request) Successful in 8s
CI / changes (push) Successful in 9s
CI / backend-tests (push) Has been skipped
CI / frontend-tests (push) Has been skipped
CI / openapi-codegen-check (push) Has been skipped
CI / frontend-tests (pull_request) Successful in 49s
CI / openapi-codegen-check (pull_request) Failing after 1m35s
CI / backend-tests (pull_request) Failing after 8m47s
fix(site-finder): correct POI-score mock shape+values, routing empty assert (#1486 review)
- Rename top_poi → items in poi-score.json to match PoiScoreResponse TS type
  (mock was cast as PoiScoreResponse but had wrong field name → items undefined
  at runtime in MOCK_POI_SCORE mode → PoiList2Gis crashed at [...items].sort)
- Recompute all score_contribution values using backend formula
  (weight / _MAX_STRAIGHT_SCORE * 100, _MAX_STRAIGHT_SCORE=0.315) and
  poi_weighted_score=19.9 (was 72, which was inconsistent with the new normalization)
- Add assert result.poi_weighted_score == 0.0 to test_routing_decay_empty_db
  to match the straight-line empty-db assertion
- Remove stale comment in PoiList2Gis.tsx saying normalization needs fixing in
  site-finder-api.ts (already done backend-side in this PR)
2026-06-17 20:47:01 +03:00

231 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* PoiList2Gis — top-7 POI list with weighted score.
* Category icons: Lucide Train / TreePine / GraduationCap / Baby /
* ShoppingBag / Hospital / Banknote
* Per row: icon + name + distance + weight badge.
*/
import {
Train,
TreePine,
GraduationCap,
Baby,
ShoppingBag,
Hospital,
Banknote,
MapPin,
} from "lucide-react";
import type { ReactNode } from "react";
import { Badge } from "@/components/ui/Badge";
import type { PoiScoreItem } from "@/lib/site-finder-api";
// ── Icon mapping ──────────────────────────────────────────────────────────────
const CATEGORY_ICONS: Record<string, ReactNode> = {
metro_stop: <Train size={16} strokeWidth={1.5} />,
tram_stop: <Train size={16} strokeWidth={1.5} />,
bus_stop: <Train size={16} strokeWidth={1.5} />,
park: <TreePine size={16} strokeWidth={1.5} />,
school: <GraduationCap size={16} strokeWidth={1.5} />,
kindergarten: <Baby size={16} strokeWidth={1.5} />,
shop_mall: <ShoppingBag size={16} strokeWidth={1.5} />,
shop_supermarket: <ShoppingBag size={16} strokeWidth={1.5} />,
shop_small: <ShoppingBag size={16} strokeWidth={1.5} />,
hospital: <Hospital size={16} strokeWidth={1.5} />,
pharmacy: <Hospital size={16} strokeWidth={1.5} />,
bank: <Banknote size={16} strokeWidth={1.5} />,
};
const CATEGORY_LABELS: Record<string, string> = {
metro_stop: "Метро",
tram_stop: "Трамвай",
bus_stop: "Автобус",
park: "Парк",
school: "Школа",
kindergarten: "Детский сад",
shop_mall: "ТЦ",
shop_supermarket: "Супермаркет",
shop_small: "Магазин",
hospital: "Больница",
pharmacy: "Аптека",
bank: "Банк",
};
// score_contribution is now 0..100 (normalized on backend, #1486).
// Thresholds: ≥20 → "отличная инфраструктура", ≥12 → "хорошая", ≥8 → "средняя", <8 → "слабая".
function weightBadgeVariant(
score_contribution: number,
): "success" | "info" | "neutral" | "warning" {
if (score_contribution >= 20) return "success";
if (score_contribution >= 12) return "info";
if (score_contribution >= 8) return "neutral";
return "warning";
}
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
items: PoiScoreItem[];
totalScore: number;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function PoiList2Gis({ items, totalScore }: Props) {
// poi_weighted_score is normalized to 0..100 on the backend (#1486).
// Clamp defensively in case of floating-point edge cases.
const displayScore = Math.min(100, Math.max(0, totalScore));
// Top-7, sorted by score_contribution desc
const top7 = [...items]
.sort((a, b) => b.score_contribution - a.score_contribution)
.slice(0, 7);
if (top7.length === 0) {
return (
<div
style={{
padding: "20px 16px",
color: "var(--fg-tertiary)",
fontSize: 13,
textAlign: "center",
border: "1px solid var(--border-card)",
borderRadius: 12,
}}
>
POI данные недоступны
</div>
);
}
return (
<div
style={{
border: "1px solid var(--border-card)",
borderRadius: 12,
overflow: "hidden",
background: "var(--bg-card)",
}}
>
{/* Header */}
<div
style={{
padding: "10px 16px",
borderBottom: "1px solid var(--border-soft)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<span
style={{
fontSize: 12,
fontWeight: 500,
textTransform: "uppercase",
letterSpacing: "0.04em",
color: "var(--fg-tertiary)",
}}
>
POI · 2ГИС / OSM
</span>
<span
style={{
fontSize: 13,
fontWeight: 700,
color: "var(--accent)",
fontVariantNumeric: "tabular-nums",
}}
>
{displayScore.toFixed(0)} / 100
</span>
</div>
{/* List */}
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
{top7.map((item, i) => {
const icon = CATEGORY_ICONS[item.category] ?? (
<MapPin size={16} strokeWidth={1.5} />
);
const categoryLabel = CATEGORY_LABELS[item.category] ?? item.category;
const isLast = i === top7.length - 1;
return (
<li
key={`${item.category}-${i}`}
style={{
display: "flex",
alignItems: "center",
gap: 12,
padding: "10px 16px",
borderBottom: isLast ? "none" : "1px solid var(--border-soft)",
background:
i % 2 === 0 ? "var(--bg-card)" : "var(--bg-card-alt)",
}}
>
{/* Icon */}
<span
style={{
color: "var(--accent)",
flexShrink: 0,
display: "flex",
alignItems: "center",
}}
aria-label={categoryLabel}
>
{icon}
</span>
{/* Name + category */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 500,
color: "var(--fg-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{item.name}
</div>
<div
style={{
fontSize: 11,
color: "var(--fg-tertiary)",
marginTop: 1,
}}
>
{categoryLabel}
</div>
</div>
{/* Distance */}
<span
style={{
fontSize: 12,
color: "var(--fg-secondary)",
fontVariantNumeric: "tabular-nums",
flexShrink: 0,
whiteSpace: "nowrap",
}}
>
{item.distance_m < 1000
? `${Math.round(item.distance_m)} м`
: `${(item.distance_m / 1000).toFixed(1)} км`}
</span>
{/* Score contribution badge (normalized 0..100, #1486) */}
<Badge
variant={weightBadgeVariant(item.score_contribution)}
size="sm"
>
{item.score_contribution.toFixed(1)}
</Badge>
</li>
);
})}
</ul>
</div>
);
}