feat(#254,#114): custom POI UI (Leaflet click-to-add) + score breakdown stacked-bar #258
11 changed files with 1552 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
332
frontend/src/components/site-finder/CustomPoiAddModal.tsx
Normal file
332
frontend/src/components/site-finder/CustomPoiAddModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
267
frontend/src/components/site-finder/CustomPoiEditModal.tsx
Normal file
267
frontend/src/components/site-finder/CustomPoiEditModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
frontend/src/components/site-finder/CustomPoiLayer.tsx
Normal file
125
frontend/src/components/site-finder/CustomPoiLayer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={{
|
||||
|
|
|
|||
463
frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx
Normal file
463
frontend/src/components/site-finder/ScoreBreakdownStackedBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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='© <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
|
||||
|
|
|
|||
128
frontend/src/hooks/useCustomPois.ts
Normal file
128
frontend/src/hooks/useCustomPois.ts
Normal 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) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
14
frontend/src/lib/sessionId.ts
Normal file
14
frontend/src/lib/sessionId.ts
Normal 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;
|
||||
}
|
||||
30
frontend/src/types/customPoi.ts
Normal file
30
frontend/src/types/customPoi.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue