Merge pull request 'feat(#254,#114): custom POI UI (Leaflet click-to-add) + score breakdown stacked-bar' (#258) from feat/254-custom-pois-ui into main
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-backend (push) Has been skipped
Deploy / build-worker (push) Has been skipped
Deploy / build-frontend (push) Successful in 3m2s
Deploy / deploy (push) Successful in 39s

This commit is contained in:
lekss361 2026-05-17 06:47:08 +00:00
commit e3eb8c873f
11 changed files with 1552 additions and 2 deletions

View file

@ -17,6 +17,7 @@ import { WeightProfilePanel } from "@/components/site-finder/WeightProfilePanel"
import { useSiteAnalysis } from "@/hooks/useSiteAnalysis";
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
import { useConnectionPoints } from "@/hooks/useConnectionPoints";
import { useCustomPois } from "@/hooks/useCustomPois";
import {
POI_DEFAULT_WEIGHTS,
type PoiCategoryKey,
@ -101,6 +102,8 @@ function SiteFinderContent() {
// Fetch connection points whenever a parcel is loaded
const { data: connectionPoints } = useConnectionPoints(data?.cad_num);
// Fetch custom POIs for current parcel
const { data: customPois } = useCustomPois(data?.cad_num);
// Weight profile state — lifted here so it survives tab switches.
// userId + adminToken allow the panel to load/save named profiles.
@ -545,6 +548,8 @@ function SiteFinderContent() {
data={data}
isochrones={isochrones}
connectionPoints={connectionPoints}
customPois={customPois}
parcelCad={data.cad_num}
/>
</aside>
</div>

View file

@ -0,0 +1,332 @@
"use client";
import { useState } from "react";
import type { CustomPoiCreate } from "@/types/customPoi";
// ---------------------------------------------------------------------------
// OSM POI category options (25 common + "Другое")
// ---------------------------------------------------------------------------
const CATEGORY_OPTIONS = [
"school",
"kindergarten",
"pharmacy",
"hospital",
"clinic",
"shop_mall",
"shop_supermarket",
"shop_small",
"park",
"tram_stop",
"bus_stop",
"metro_stop",
"cafe",
"restaurant",
"bank",
"atm",
"post_office",
"library",
"sports_centre",
"gym",
"cinema",
"theatre",
"hotel",
"fuel",
"parking",
"Другое",
] as const;
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface Props {
lon: number;
lat: number;
parcelCad?: string | null;
onSubmit: (data: CustomPoiCreate) => void;
onCancel: () => void;
isLoading?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CustomPoiAddModal({
lon,
lat,
parcelCad,
onSubmit,
onCancel,
isLoading = false,
}: Props) {
const [name, setName] = useState("");
const [category, setCategory] = useState<string>("Другое");
const [weight, setWeight] = useState(0);
const [notes, setNotes] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
setNameError("Название обязательно");
return;
}
setNameError(null);
onSubmit({
name: name.trim(),
category: category === "Другое" ? null : category,
weight,
lon,
lat,
parcel_cad: parcelCad ?? null,
notes: notes.trim() || null,
});
}
// Derived color for weight indicator
const weightColor =
weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
return (
/* Backdrop */
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div
style={{
background: "#fff",
borderRadius: 14,
padding: "24px 28px",
width: 380,
maxWidth: "calc(100vw - 40px)",
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 18px",
fontSize: 17,
fontWeight: 700,
color: "#111827",
}}
>
Добавить точку интереса
</h3>
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
{/* Coordinates (read-only) */}
<div style={{ fontSize: 12, color: "#9ca3af" }}>
{lat.toFixed(5)}, {lon.toFixed(5)}
{parcelCad && (
<span style={{ marginLeft: 8, color: "#6b7280" }}>
· {parcelCad}
</span>
)}
</div>
{/* Name */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Название *
</label>
<input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError(null);
}}
placeholder="Например: Детская площадка"
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: `1px solid ${nameError ? "#f87171" : "#d1d5db"}`,
borderRadius: 7,
boxSizing: "border-box",
}}
autoFocus
/>
{nameError && (
<p style={{ margin: "3px 0 0", fontSize: 11, color: "#dc2626" }}>
{nameError}
</p>
)}
</div>
{/* Category */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Категория
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
background: "#fff",
boxSizing: "border-box",
}}
>
{CATEGORY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
{/* Weight slider */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Вес{" "}
<span style={{ fontWeight: 700, color: weightColor }}>
{weight > 0 ? `+${weight}` : weight}
</span>
</label>
<input
type="range"
min={-5}
max={5}
step={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
style={{ width: "100%" }}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
fontSize: 11,
color: "#9ca3af",
marginTop: 2,
}}
>
<span>-5 (отрицательный)</span>
<span>0</span>
<span>+5 (положительный)</span>
</div>
</div>
{/* Notes */}
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Заметки
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Необязательно"
rows={2}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
{/* Buttons */}
<div
style={{
display: "flex",
gap: 10,
justifyContent: "flex-end",
marginTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
style={{
padding: "8px 18px",
fontSize: 13,
borderRadius: 7,
border: "1px solid #d1d5db",
background: "#f9fafb",
cursor: "pointer",
color: "#374151",
}}
disabled={isLoading}
>
Отмена
</button>
<button
type="submit"
style={{
padding: "8px 20px",
fontSize: 13,
borderRadius: 7,
border: "none",
background: isLoading ? "#93c5fd" : "#1d4ed8",
color: "#fff",
cursor: isLoading ? "not-allowed" : "pointer",
fontWeight: 600,
}}
disabled={isLoading}
>
{isLoading ? "Сохранение…" : "Добавить"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,267 @@
"use client";
import { useState } from "react";
import type { CustomPoi, CustomPoiUpdate } from "@/types/customPoi";
interface Props {
poi: CustomPoi;
onSubmit: (data: CustomPoiUpdate) => void;
onCancel: () => void;
isLoading?: boolean;
}
const CATEGORY_OPTIONS = [
"school",
"kindergarten",
"pharmacy",
"hospital",
"clinic",
"shop_mall",
"shop_supermarket",
"shop_small",
"park",
"tram_stop",
"bus_stop",
"metro_stop",
"cafe",
"restaurant",
"bank",
"atm",
"post_office",
"library",
"sports_centre",
"gym",
"cinema",
"theatre",
"hotel",
"fuel",
"parking",
"Другое",
] as const;
export function CustomPoiEditModal({
poi,
onSubmit,
onCancel,
isLoading = false,
}: Props) {
const [name, setName] = useState(poi.name);
const [category, setCategory] = useState<string>(poi.category ?? "Другое");
const [weight, setWeight] = useState(poi.weight);
const [notes, setNotes] = useState(poi.notes ?? "");
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
onSubmit({
name: name.trim() || poi.name,
category: category === "Другое" ? null : category,
weight,
notes: notes.trim() || null,
});
}
const weightColor =
weight > 0 ? "#16a34a" : weight < 0 ? "#dc2626" : "#6b7280";
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.35)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
>
<div
style={{
background: "#fff",
borderRadius: 14,
padding: "24px 28px",
width: 380,
maxWidth: "calc(100vw - 40px)",
boxShadow: "0 20px 40px rgba(0,0,0,0.18)",
}}
onClick={(e) => e.stopPropagation()}
>
<h3
style={{
margin: "0 0 18px",
fontSize: 17,
fontWeight: 700,
color: "#111827",
}}
>
Изменить точку
</h3>
<form
onSubmit={handleSubmit}
style={{ display: "flex", flexDirection: "column", gap: 14 }}
>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Название
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
boxSizing: "border-box",
}}
autoFocus
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Категория
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
background: "#fff",
boxSizing: "border-box",
}}
>
{CATEGORY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Вес{" "}
<span style={{ fontWeight: 700, color: weightColor }}>
{weight > 0 ? `+${weight}` : weight}
</span>
</label>
<input
type="range"
min={-5}
max={5}
step={1}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
style={{ width: "100%" }}
/>
</div>
<div>
<label
style={{
display: "block",
fontSize: 12,
fontWeight: 600,
color: "#374151",
marginBottom: 4,
}}
>
Заметки
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
style={{
width: "100%",
padding: "8px 10px",
fontSize: 13,
border: "1px solid #d1d5db",
borderRadius: 7,
resize: "vertical",
boxSizing: "border-box",
}}
/>
</div>
<div
style={{
display: "flex",
gap: 10,
justifyContent: "flex-end",
marginTop: 4,
}}
>
<button
type="button"
onClick={onCancel}
style={{
padding: "8px 18px",
fontSize: 13,
borderRadius: 7,
border: "1px solid #d1d5db",
background: "#f9fafb",
cursor: "pointer",
color: "#374151",
}}
disabled={isLoading}
>
Отмена
</button>
<button
type="submit"
style={{
padding: "8px 20px",
fontSize: 13,
borderRadius: 7,
border: "none",
background: isLoading ? "#93c5fd" : "#1d4ed8",
color: "#fff",
cursor: isLoading ? "not-allowed" : "pointer",
fontWeight: 600,
}}
disabled={isLoading}
>
{isLoading ? "Сохранение…" : "Сохранить"}
</button>
</div>
</form>
</div>
</div>
);
}

View file

@ -0,0 +1,125 @@
"use client";
import { CircleMarker, LayerGroup, Popup } from "react-leaflet";
import type { CustomPoi } from "@/types/customPoi";
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface Props {
pois: CustomPoi[];
onEdit: (poi: CustomPoi) => void;
onDelete: (id: number) => void;
}
// ---------------------------------------------------------------------------
// Color helpers
// ---------------------------------------------------------------------------
function markerColor(weight: number): string {
if (weight > 0) return "#16a34a"; // green — positive
if (weight < 0) return "#dc2626"; // red — negative
return "#6b7280"; // gray — neutral
}
function weightLabel(weight: number): string {
if (weight > 0) return `+${weight}`;
return String(weight);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function CustomPoiLayer({ pois, onEdit, onDelete }: Props) {
if (!pois.length) return null;
return (
<LayerGroup>
{pois.map((poi) => {
const color = markerColor(poi.weight);
return (
<CircleMarker
key={poi.id}
center={[poi.lat, poi.lon]}
radius={9}
pathOptions={{
color,
fillColor: color,
fillOpacity: 0.85,
weight: 2.5,
dashArray: "4 2",
}}
>
<Popup>
<div style={{ fontSize: 13, lineHeight: 1.6, minWidth: 160 }}>
<div
style={{ fontWeight: 700, marginBottom: 2, color: "#111827" }}
>
{poi.name}
</div>
{poi.category && (
<div
style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}
>
{poi.category}
</div>
)}
<div
style={{
fontWeight: 700,
color,
marginBottom: 6,
fontSize: 14,
}}
>
Вес: {weightLabel(poi.weight)}
</div>
{poi.notes && (
<div
style={{ fontSize: 12, color: "#374151", marginBottom: 8 }}
>
{poi.notes}
</div>
)}
<div style={{ display: "flex", gap: 8 }}>
<button
type="button"
onClick={() => onEdit(poi)}
style={{
padding: "4px 10px",
fontSize: 12,
borderRadius: 5,
border: "1px solid #d1d5db",
background: "#f9fafb",
cursor: "pointer",
color: "#374151",
}}
>
Изменить
</button>
<button
type="button"
onClick={() => onDelete(poi.id)}
style={{
padding: "4px 10px",
fontSize: 12,
borderRadius: 5,
border: "none",
background: "#fef2f2",
color: "#dc2626",
cursor: "pointer",
}}
>
Удалить
</button>
</div>
</div>
</Popup>
</CircleMarker>
);
})}
</LayerGroup>
);
}

View file

@ -0,0 +1,38 @@
"use client";
interface Props {
active: boolean;
onClick: () => void;
}
/**
* Floating button shown below the map to toggle "add custom POI" click mode.
* When active, cursor becomes crosshair and next map click opens the add modal.
*/
export function CustomPoiToggleButton({ active, onClick }: Props) {
return (
<button
type="button"
onClick={onClick}
style={{
padding: "7px 14px",
fontSize: 13,
fontWeight: 600,
borderRadius: 8,
border: active ? "2px solid #1d4ed8" : "1px solid #d1d5db",
background: active ? "#dbeafe" : "#fff",
color: active ? "#1d4ed8" : "#374151",
cursor: "pointer",
transition: "all 0.15s",
whiteSpace: "nowrap",
}}
title={
active
? "Кликните на карте для добавления точки. Нажмите снова для отмены"
: "Кликните для добавления пользовательской точки на карте"
}
>
{active ? "Отмена (нажмите Esc или эту кнопку)" : "+ Добавить точку"}
</button>
);
}

View file

@ -7,6 +7,7 @@ import { ConfidenceBadge } from "./ConfidenceBadge";
import { GateVerdictBanner } from "./GateVerdictBanner";
import { IsochronesPanel } from "./IsochronesPanel";
import { ScoreBreakdownPanel } from "./ScoreBreakdownPanel";
import { ScoreBreakdownStackedBar } from "./ScoreBreakdownStackedBar";
interface Props {
data: ParcelAnalysis;
@ -161,6 +162,12 @@ export function OverviewTab({ data, onIsochronesResult }: Props) {
/>
)}
{/* #114: score breakdown stacked bar by category */}
{data.score_breakdown_detailed &&
data.score_breakdown_detailed.length > 0 && (
<ScoreBreakdownStackedBar breakdown={data.score_breakdown_detailed} />
)}
{/* POI breakdown */}
<div
style={{

View file

@ -0,0 +1,463 @@
"use client";
import { useState } from "react";
import type { FactorContribution } from "@/types/site-finder";
// ---------------------------------------------------------------------------
// Color mapping per category
// ---------------------------------------------------------------------------
const CATEGORY_COLORS: Record<string, string> = {
school: "#1d4ed8",
kindergarten: "#1d4ed8",
pharmacy: "#10b981",
hospital: "#f59e0b",
shop_mall: "#a855f7",
shop_supermarket: "#a855f7",
shop_small: "#c084fc",
park: "#16a34a",
tram_stop: "#dc2626",
bus_stop: "#6b7280",
metro_stop: "#1e40af",
custom: "#f97316", // custom POIs — orange with border
};
function categoryColor(category: string): string {
return CATEGORY_COLORS[category] ?? "#94a3b8";
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface Props {
/** Full factor breakdown from ParcelAnalysis.score_breakdown_detailed */
breakdown: FactorContribution[];
/** Factor names that come from custom POIs (to highlight visually) */
customPoiFactors?: string[];
}
// ---------------------------------------------------------------------------
// Aggregation helpers
// ---------------------------------------------------------------------------
interface CategoryAgg {
category: string;
category_ru: string;
contribution: number;
count: number;
isCustom: boolean;
}
function aggregateByCategory(
items: FactorContribution[],
customFactors: Set<string>,
): CategoryAgg[] {
const map = new Map<string, CategoryAgg>();
for (const item of items) {
const key = item.category;
const existing = map.get(key);
if (existing) {
existing.contribution += item.contribution;
existing.count += 1;
if (customFactors.has(item.factor)) existing.isCustom = true;
} else {
map.set(key, {
category: key,
category_ru: item.category_ru,
contribution: item.contribution,
count: 1,
isCustom: customFactors.has(item.factor),
});
}
}
return [...map.values()].sort((a, b) => b.contribution - a.contribution);
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function ScoreBreakdownStackedBar({
breakdown,
customPoiFactors = [],
}: Props) {
const [groupBy, setGroupBy] = useState<"category" | "source">("category");
const [hoveredCat, setHoveredCat] = useState<string | null>(null);
const customSet = new Set(customPoiFactors);
// Aggregate by category (or source as fallback label when groupBy=source)
const aggregated = aggregateByCategory(breakdown, customSet);
// Split positive and negative for stacked bar
const positives = aggregated.filter((a) => a.contribution > 0);
const negatives = aggregated.filter((a) => a.contribution < 0);
const totalPositive = positives.reduce((s, a) => s + a.contribution, 0) || 1;
const totalNegative =
Math.abs(negatives.reduce((s, a) => s + a.contribution, 0)) || 1;
if (aggregated.length === 0) return null;
const fmtV = (v: number) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}`;
// Group-by toggle (source mode uses factor field as key — simpler visual)
const sourceAgg =
groupBy === "source"
? (() => {
const sm = new Map<string, { contribution: number; count: number }>();
for (const item of breakdown) {
const key = item.factor;
const ex = sm.get(key);
if (ex) {
ex.contribution += item.contribution;
ex.count += 1;
} else {
sm.set(key, { contribution: item.contribution, count: 1 });
}
}
return [...sm.entries()]
.map(([k, v]) => ({ factor: k, ...v }))
.sort((a, b) => b.contribution - a.contribution)
.slice(0, 10);
})()
: null;
return (
<div
style={{
border: "1px solid #e5e7eb",
borderRadius: 10,
padding: "14px 18px",
background: "#fff",
display: "flex",
flexDirection: "column",
gap: 14,
}}
>
{/* Header + toggle */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: 8,
}}
>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: "0.05em",
}}
>
Вклад по категориям
</div>
<div style={{ display: "flex", gap: 6, fontSize: 12 }}>
<button
type="button"
onClick={() => setGroupBy("category")}
style={{
padding: "3px 10px",
borderRadius: 5,
border: "1px solid",
borderColor: groupBy === "category" ? "#1d4ed8" : "#d1d5db",
background: groupBy === "category" ? "#dbeafe" : "#f9fafb",
color: groupBy === "category" ? "#1d4ed8" : "#6b7280",
cursor: "pointer",
fontWeight: groupBy === "category" ? 600 : 400,
}}
>
По категории
</button>
<button
type="button"
onClick={() => setGroupBy("source")}
style={{
padding: "3px 10px",
borderRadius: 5,
border: "1px solid",
borderColor: groupBy === "source" ? "#1d4ed8" : "#d1d5db",
background: groupBy === "source" ? "#dbeafe" : "#f9fafb",
color: groupBy === "source" ? "#1d4ed8" : "#6b7280",
cursor: "pointer",
fontWeight: groupBy === "source" ? 600 : 400,
}}
>
По фактору
</button>
</div>
</div>
{/* Stacked horizontal bar — positive contributions */}
{groupBy === "category" && positives.length > 0 && (
<div>
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 4 }}>
Положительный вклад
</div>
<div
style={{
display: "flex",
height: 28,
borderRadius: 5,
overflow: "hidden",
border: "1px solid #e5e7eb",
}}
role="img"
aria-label="Stacked bar: положительный вклад по категориям"
>
{positives.map((agg) => {
const widthPct = (agg.contribution / totalPositive) * 100;
const color = agg.isCustom
? CATEGORY_COLORS.custom
: categoryColor(agg.category);
const isHovered = hoveredCat === agg.category;
return (
<div
key={agg.category}
style={{
width: `${widthPct}%`,
background: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 11,
color: "#fff",
fontWeight: 500,
overflow: "hidden",
whiteSpace: "nowrap",
cursor: "default",
outline: agg.isCustom ? "2px dashed #ea580c" : "none",
outlineOffset: -2,
opacity: hoveredCat && !isHovered ? 0.6 : 1,
transition: "opacity 0.15s",
}}
title={`${agg.category_ru}${agg.isCustom ? " (custom)" : ""}: ${fmtV(agg.contribution)}`}
onMouseEnter={() => setHoveredCat(agg.category)}
onMouseLeave={() => setHoveredCat(null)}
>
{widthPct >= 12 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
{/* Negative bar */}
{negatives.length > 0 && (
<>
<div
style={{
fontSize: 11,
color: "#6b7280",
marginTop: 10,
marginBottom: 4,
}}
>
Снижают балл
</div>
<div
style={{
display: "flex",
height: 20,
borderRadius: 5,
overflow: "hidden",
border: "1px solid #fca5a5",
}}
>
{negatives.map((agg) => {
const widthPct =
(Math.abs(agg.contribution) / totalNegative) * 100;
const color = categoryColor(agg.category);
return (
<div
key={agg.category}
style={{
width: `${widthPct}%`,
background: color,
opacity: 0.6,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 10,
color: "#fff",
overflow: "hidden",
whiteSpace: "nowrap",
cursor: "default",
}}
title={`${agg.category_ru}: ${fmtV(agg.contribution)}`}
>
{widthPct >= 15 ? `${Math.round(widthPct)}%` : ""}
</div>
);
})}
</div>
</>
)}
{/* Legend */}
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
marginTop: 10,
fontSize: 12,
color: "#6b7280",
}}
>
{aggregated.map((agg) => {
const color = agg.isCustom
? CATEGORY_COLORS.custom
: categoryColor(agg.category);
return (
<div
key={agg.category}
style={{
display: "flex",
alignItems: "center",
gap: 4,
cursor: "default",
}}
onMouseEnter={() => setHoveredCat(agg.category)}
onMouseLeave={() => setHoveredCat(null)}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: color,
outline: agg.isCustom ? "1.5px dashed #ea580c" : "none",
outlineOffset: 1,
}}
/>
<span>
{agg.category_ru}
{agg.isCustom && (
<span
style={{
marginLeft: 3,
fontSize: 10,
color: "#ea580c",
fontWeight: 600,
}}
>
(custom)
</span>
)}
:{" "}
<strong
style={{
color: agg.contribution >= 0 ? "#16a34a" : "#dc2626",
}}
>
{fmtV(agg.contribution)}
</strong>
</span>
</div>
);
})}
</div>
</div>
)}
{/* Source (factor) view — top 10 table */}
{groupBy === "source" && sourceAgg && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
{sourceAgg.map(({ factor, contribution }) => {
const isCustom = customSet.has(factor);
const barWidth = Math.min(
100,
(Math.abs(contribution) /
(Math.abs(sourceAgg[0]?.contribution) || 1)) *
100,
);
const color =
contribution >= 0
? isCustom
? CATEGORY_COLORS.custom
: "#16a34a"
: "#dc2626";
return (
<div
key={factor}
style={{ display: "flex", alignItems: "center", gap: 10 }}
>
<div
style={{
fontSize: 12,
color: "#374151",
width: 180,
flexShrink: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={factor}
>
{isCustom && (
<span
style={{
fontSize: 10,
color: "#ea580c",
fontWeight: 600,
marginRight: 4,
}}
>
[custom]
</span>
)}
{factor}
</div>
<div
style={{
flex: 1,
height: 14,
background: "#f3f4f6",
borderRadius: 3,
overflow: "hidden",
}}
>
<div
style={{
width: `${barWidth}%`,
height: "100%",
background: color,
borderRadius: 3,
outline: isCustom ? "1.5px dashed #ea580c" : "none",
}}
/>
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color,
width: 50,
textAlign: "right",
flexShrink: 0,
fontVariantNumeric: "tabular-nums",
}}
>
{fmtV(contribution)}
</div>
</div>
);
})}
{breakdown.length > 10 && (
<div style={{ fontSize: 11, color: "#9ca3af", marginTop: 4 }}>
Показаны топ-10 из {breakdown.length} факторов
</div>
)}
</div>
)}
</div>
);
}

View file

@ -1,12 +1,13 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
MapContainer,
TileLayer,
GeoJSON,
CircleMarker,
Popup,
useMapEvents,
} from "react-leaflet";
import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import "leaflet/dist/leaflet.css";
@ -16,6 +17,7 @@ import type {
ConnectionPointsResponse,
EngineeringStructure,
} from "@/types/nspd";
import type { CustomPoi } from "@/types/customPoi";
import {
ConnectionPointsLayer,
CP_ALL_CATEGORIES,
@ -23,6 +25,15 @@ import {
type CpCategory,
} from "@/components/site-finder/ConnectionPointsLayer";
import { CpLayerControlPanel } from "@/components/site-finder/CpLayerControlPanel";
import { CustomPoiLayer } from "@/components/site-finder/CustomPoiLayer";
import { CustomPoiToggleButton } from "@/components/site-finder/CustomPoiToggleButton";
import { CustomPoiAddModal } from "@/components/site-finder/CustomPoiAddModal";
import { CustomPoiEditModal } from "@/components/site-finder/CustomPoiEditModal";
import {
useAddCustomPoi,
useUpdateCustomPoi,
useDeleteCustomPoi,
} from "@/hooks/useCustomPois";
// ---------------------------------------------------------------------------
// POI legend config (for the legend row below the map)
@ -95,9 +106,37 @@ interface Props {
data: ParcelAnalysis;
isochrones?: FeatureCollection;
connectionPoints?: ConnectionPointsResponse;
customPois?: CustomPoi[];
parcelCad?: string;
}
export function SiteMap({ data, isochrones, connectionPoints }: Props) {
// ---------------------------------------------------------------------------
// Map click handler sub-component (must be inside MapContainer)
// ---------------------------------------------------------------------------
interface MapClickHandlerProps {
editMode: boolean;
onMapClick: (lat: number, lon: number) => void;
}
function MapClickHandler({ editMode, onMapClick }: MapClickHandlerProps) {
useMapEvents({
click(e) {
if (editMode) {
onMapClick(e.latlng.lat, e.latlng.lng);
}
},
});
return null;
}
export function SiteMap({
data,
isochrones,
connectionPoints,
customPois,
parcelCad,
}: Props) {
// Fix Leaflet default icon paths broken by webpack bundler
useEffect(() => {
void import("leaflet").then((L) => {
@ -118,6 +157,33 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
new Set(CP_ALL_CATEGORIES),
);
// Custom POI add mode state
const [addMode, setAddMode] = useState(false);
const [pendingCoords, setPendingCoords] = useState<{
lat: number;
lon: number;
} | null>(null);
const [editingPoi, setEditingPoi] = useState<CustomPoi | null>(null);
// Mutations
const addMutation = useAddCustomPoi(parcelCad);
const updateMutation = useUpdateCustomPoi(parcelCad);
const deleteMutation = useDeleteCustomPoi(parcelCad);
// Keyboard ESC cancels add mode
const addModeRef = useRef(addMode);
addModeRef.current = addMode;
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && addModeRef.current) {
setAddMode(false);
setPendingCoords(null);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
function toggleCategory(cat: CpCategory) {
setVisibleCategories((prev) => {
const next = new Set(prev);
@ -138,6 +204,11 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
);
}
function handleMapClick(lat: number, lon: number) {
setPendingCoords({ lat, lon });
setAddMode(false); // toggle off click mode; modal opens
}
const center: [number, number] = data.geom_geojson
? geomCenter(data.geom_geojson)
: [56.838, 60.6];
@ -159,6 +230,7 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
borderRadius: 12,
overflow: "hidden",
height: 420,
cursor: addMode ? "crosshair" : "grab",
}}
>
<MapContainer
@ -167,6 +239,7 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
style={{ height: "100%", width: "100%" }}
scrollWheelZoom
>
<MapClickHandler editMode={addMode} onMapClick={handleMapClick} />
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
@ -272,9 +345,77 @@ export function SiteMap({ data, isochrones, connectionPoints }: Props) {
visibleCategories={visibleCategories}
/>
)}
{/* Custom POI markers — topmost layer */}
{customPois && customPois.length > 0 && (
<CustomPoiLayer
pois={customPois}
onEdit={(poi) => setEditingPoi(poi)}
onDelete={(id) => deleteMutation.mutate(id)}
/>
)}
</MapContainer>
</div>
{/* Custom POI add-mode toggle button */}
<div
style={{ marginTop: 8, display: "flex", gap: 10, alignItems: "center" }}
>
<CustomPoiToggleButton
active={addMode}
onClick={() => {
setAddMode((v) => !v);
setPendingCoords(null);
}}
/>
{addMode && (
<span style={{ fontSize: 12, color: "#6b7280" }}>
Кликните на карте для добавления точки
</span>
)}
{customPois && customPois.length > 0 && (
<span style={{ fontSize: 12, color: "#374151", marginLeft: "auto" }}>
{customPois.length} польз.{" "}
{customPois.length === 1
? "точка"
: customPois.length < 5
? "точки"
: "точек"}
</span>
)}
</div>
{/* Add modal */}
{pendingCoords && (
<CustomPoiAddModal
lat={pendingCoords.lat}
lon={pendingCoords.lon}
parcelCad={parcelCad ?? null}
isLoading={addMutation.isPending}
onSubmit={(payload) => {
addMutation.mutate(payload, {
onSuccess: () => setPendingCoords(null),
});
}}
onCancel={() => setPendingCoords(null)}
/>
)}
{/* Edit modal */}
{editingPoi && (
<CustomPoiEditModal
poi={editingPoi}
isLoading={updateMutation.isPending}
onSubmit={(updateData) => {
updateMutation.mutate(
{ id: editingPoi.id, data: updateData },
{ onSuccess: () => setEditingPoi(null) },
);
}}
onCancel={() => setEditingPoi(null)}
/>
)}
{/* POI category legend */}
{presentCategories.length > 0 && (
<div

View file

@ -0,0 +1,128 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import { getOrCreateSessionId } from "@/lib/sessionId";
import type {
CustomPoi,
CustomPoiCreate,
CustomPoiUpdate,
} from "@/types/customPoi";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function sessionHeaders(): Record<string, string> {
const id = getOrCreateSessionId();
return id ? { "X-Session-Id": id } : {};
}
// ---------------------------------------------------------------------------
// Query key factory
// ---------------------------------------------------------------------------
function customPoisKey(parcelCad?: string | null): unknown[] {
return ["custom-pois", parcelCad ?? null];
}
function analyzeKey(parcelCad: string): unknown[] {
return ["analyze", parcelCad];
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
/**
* List custom POIs, optionally filtered by parcel cad number.
*/
export function useCustomPois(parcelCad?: string | null) {
return useQuery({
queryKey: customPoisKey(parcelCad),
queryFn: () => {
const qs = parcelCad
? `?parcel_cad=${encodeURIComponent(parcelCad)}`
: "";
return apiFetch<CustomPoi[]>(`/api/v1/custom-pois${qs}`, {
headers: sessionHeaders(),
});
},
// Don't auto-fetch until session available (SSR guard).
enabled: typeof window !== "undefined",
staleTime: 30_000,
});
}
/**
* Create a custom POI. Invalidates list + analyze query on success.
*/
export function useAddCustomPoi(parcelCad?: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (payload: CustomPoiCreate) =>
apiFetch<CustomPoi>("/api/v1/custom-pois", {
method: "POST",
body: JSON.stringify(payload),
headers: { ...sessionHeaders() },
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: customPoisKey(parcelCad),
});
// Invalidate global list too
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
if (parcelCad) {
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
}
},
});
}
/**
* Update a custom POI by id.
*/
export function useUpdateCustomPoi(parcelCad?: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: CustomPoiUpdate }) =>
apiFetch<CustomPoi>(`/api/v1/custom-pois/${id}`, {
method: "PATCH",
body: JSON.stringify(data),
headers: { ...sessionHeaders() },
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: customPoisKey(parcelCad),
});
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
if (parcelCad) {
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
}
},
});
}
/**
* Delete a custom POI by id.
*/
export function useDeleteCustomPoi(parcelCad?: string | null) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) =>
apiFetch<unknown>(`/api/v1/custom-pois/${id}`, {
method: "DELETE",
headers: sessionHeaders(),
}),
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: customPoisKey(parcelCad),
});
void queryClient.invalidateQueries({ queryKey: customPoisKey(null) });
if (parcelCad) {
void queryClient.invalidateQueries({ queryKey: analyzeKey(parcelCad) });
}
},
});
}

View file

@ -0,0 +1,14 @@
/**
* Session ID utility for custom POI auth.
* Stored in localStorage under "session_id" key.
* Returns empty string during SSR (window not available).
*/
export function getOrCreateSessionId(): string {
if (typeof window === "undefined") return "";
let id = localStorage.getItem("session_id");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("session_id", id);
}
return id;
}

View file

@ -0,0 +1,30 @@
export interface CustomPoi {
id: number;
user_id: string;
parcel_cad: string | null;
name: string;
category: string | null;
weight: number;
lon: number;
lat: number;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface CustomPoiCreate {
name: string;
category?: string | null;
weight: number;
lon: number;
lat: number;
parcel_cad?: string | null;
notes?: string | null;
}
export interface CustomPoiUpdate {
name?: string;
category?: string | null;
weight?: number;
notes?: string | null;
}