Task A (#254): Leaflet click-to-add custom POIs - sessionId.ts: getOrCreateSessionId() from localStorage - types/customPoi.ts: CustomPoi, CustomPoiCreate, CustomPoiUpdate - hooks/useCustomPois.ts: useCustomPois / useAddCustomPoi / useUpdateCustomPoi / useDeleteCustomPoi (TanStack Query, X-Session-Id header) - CustomPoiLayer.tsx: LayerGroup with CircleMarker (green/red/gray by weight), popup with edit/delete - CustomPoiToggleButton.tsx: crosshair mode toggle button - CustomPoiAddModal.tsx: modal form (name, 26 categories, weight -5..+5, notes) - CustomPoiEditModal.tsx: same form for editing existing POI - SiteMap.tsx: MapClickHandler (useMapEvents), integrated all custom POI components - page.tsx: useCustomPois hook + passes customPois + parcelCad to SiteMap Task B (#114): ScoreBreakdownStackedBar - ScoreBreakdownStackedBar.tsx: horizontal stacked bar by category with positive/negative split, toggle between category and factor views, custom POIs highlighted orange with dashed outline - OverviewTab.tsx: renders ScoreBreakdownStackedBar below ScoreBreakdownPanel Requires backend PR with /api/v1/custom-pois endpoints merged before POI CRUD works.
125 lines
3.8 KiB
TypeScript
125 lines
3.8 KiB
TypeScript
"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>
|
||
);
|
||
}
|