feat(site-finder): cad_parcels_geom migration + POI markers on map + bulk-job UX
This commit is contained in:
parent
f419900968
commit
81cd7499f6
4 changed files with 106 additions and 12 deletions
26
data/sql/83_cad_parcels_geom.sql
Normal file
26
data/sql/83_cad_parcels_geom.sql
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
-- 83_cad_parcels_geom.sql
|
||||||
|
-- Context : хранение геометрии участков (4-part cad: XX:YY:ZZZZZZZ:NN) из NSPD (thematic_id=1).
|
||||||
|
-- По аналогии с cad_quarters_geom (3-part cad).
|
||||||
|
-- Dependency: PostGIS extension уже установлен (используется cad_quarters_geom, cad_buildings).
|
||||||
|
-- Deploy order: standalone, нет FK-зависимостей от других таблиц.
|
||||||
|
-- Идемпотентность: безопасно повторно запускать (IF NOT EXISTS).
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cad_parcels_geom (
|
||||||
|
cad_num TEXT PRIMARY KEY, -- 4-part cad: 66:41:0204016:10
|
||||||
|
geom GEOMETRY(Geometry, 4326) NOT NULL, -- Polygon | MultiPolygon
|
||||||
|
raw_props JSONB DEFAULT '{}'::jsonb,
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE cad_parcels_geom IS
|
||||||
|
'Геометрия участков (4-part cad, thematic_id=1) из NSPD. '
|
||||||
|
'Источник: nspd_geo task. '
|
||||||
|
'Используется site-finder analyze endpoint как fallback для 4-part cad '
|
||||||
|
'если в cad_quarters_geom нет.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS cad_parcels_geom_gist
|
||||||
|
ON cad_parcels_geom USING GIST (geom);
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
@ -73,11 +73,14 @@ export default function GeoScrapeAdminPage() {
|
||||||
|
|
||||||
// Авто-синк thematic_id при смене job_kind. Пользователь может override через
|
// Авто-синк thematic_id при смене job_kind. Пользователь может override через
|
||||||
// селект ниже (например quarters + thematic_id=15 для комплексов).
|
// селект ниже (например quarters + thematic_id=15 для комплексов).
|
||||||
|
// Также переключаем source на manual_list когда job_kind != quarters, т.к.
|
||||||
|
// rosreestr_pending содержит только кварталы (иначе backend вернёт 400).
|
||||||
const handleJobKindChange = (kind: "quarters" | "parcels" | "buildings") => {
|
const handleJobKindChange = (kind: "quarters" | "parcels" | "buildings") => {
|
||||||
setJobKind(kind);
|
setJobKind(kind);
|
||||||
if (kind === "quarters") setThematicId(2);
|
if (kind === "quarters") setThematicId(2);
|
||||||
else if (kind === "parcels") setThematicId(1);
|
else if (kind === "parcels") setThematicId(1);
|
||||||
else if (kind === "buildings") setThematicId(5);
|
else if (kind === "buildings") setThematicId(5);
|
||||||
|
if (kind !== "quarters") setSourceKind("manual_list");
|
||||||
};
|
};
|
||||||
const [cadNumsText, setCadNumsText] = useState("");
|
const [cadNumsText, setCadNumsText] = useState("");
|
||||||
const [regionCodes, setRegionCodes] = useState("66,74,72,59");
|
const [regionCodes, setRegionCodes] = useState("66,74,72,59");
|
||||||
|
|
@ -306,6 +309,23 @@ export default function GeoScrapeAdminPage() {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{sourceKind === "rosreestr_pending" && jobKind !== "quarters" && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: "#fef3c7",
|
||||||
|
border: "1px solid #f59e0b",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "10px 14px",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#78350f",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
rosreestr_pending пока работает только для кварталов. Для
|
||||||
|
участков/зданий используй manual_list с cad-номерами через
|
||||||
|
запятую.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { MapContainer, TileLayer, GeoJSON } from "react-leaflet";
|
import {
|
||||||
|
MapContainer,
|
||||||
|
TileLayer,
|
||||||
|
GeoJSON,
|
||||||
|
CircleMarker,
|
||||||
|
Popup,
|
||||||
|
} from "react-leaflet";
|
||||||
import type { Geometry, Position } from "geojson";
|
import type { Geometry, Position } from "geojson";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
|
|
||||||
|
|
@ -14,20 +20,21 @@ import type { ParcelAnalysis } from "@/types/site-finder";
|
||||||
interface CategoryStyle {
|
interface CategoryStyle {
|
||||||
color: string;
|
color: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
radius: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CATEGORY_STYLES: Record<string, CategoryStyle> = {
|
export const CATEGORY_STYLES: Record<string, CategoryStyle> = {
|
||||||
school: { color: "#2563eb", label: "Школа" },
|
school: { color: "#1d4ed8", label: "Школа", radius: 8 },
|
||||||
kindergarten: { color: "#0891b2", label: "Детский сад" },
|
kindergarten: { color: "#1d4ed8", label: "Детский сад", radius: 8 },
|
||||||
pharmacy: { color: "#16a34a", label: "Аптека" },
|
pharmacy: { color: "#10b981", label: "Аптека", radius: 6 },
|
||||||
hospital: { color: "#059669", label: "Больница" },
|
hospital: { color: "#f59e0b", label: "Больница", radius: 8 },
|
||||||
shop_mall: { color: "#f59e0b", label: "ТЦ" },
|
shop_mall: { color: "#a855f7", label: "ТЦ", radius: 8 },
|
||||||
shop_supermarket: { color: "#f97316", label: "Супермаркет" },
|
shop_supermarket: { color: "#a855f7", label: "Супермаркет", radius: 6 },
|
||||||
shop_small: { color: "#fb923c", label: "Магазин" },
|
shop_small: { color: "#c084fc", label: "Магазин", radius: 6 },
|
||||||
park: { color: "#166534", label: "Парк" },
|
park: { color: "#16a34a", label: "Парк", radius: 6 },
|
||||||
tram_stop: { color: "#dc2626", label: "Трамвай" },
|
tram_stop: { color: "#dc2626", label: "Трамвай", radius: 6 },
|
||||||
bus_stop: { color: "#9ca3af", label: "Автобус" },
|
bus_stop: { color: "#6b7280", label: "Автобус", radius: 6 },
|
||||||
metro_stop: { color: "#7c3aed", label: "Метро" },
|
metro_stop: { color: "#1e40af", label: "Метро", radius: 8 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -135,6 +142,45 @@ export function SiteMap({ data }: Props) {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* POI markers */}
|
||||||
|
{Object.entries(data.score_breakdown).flatMap(([cat, pois]) => {
|
||||||
|
const style = CATEGORY_STYLES[cat];
|
||||||
|
if (!style) return [];
|
||||||
|
return pois
|
||||||
|
.filter((poi) => poi.lat !== 0 && poi.lon !== 0)
|
||||||
|
.map((poi, idx) => (
|
||||||
|
<CircleMarker
|
||||||
|
key={`${cat}-${idx}`}
|
||||||
|
center={[poi.lat, poi.lon]}
|
||||||
|
radius={style.radius}
|
||||||
|
pathOptions={{
|
||||||
|
color: style.color,
|
||||||
|
fillColor: style.color,
|
||||||
|
fillOpacity: 0.85,
|
||||||
|
weight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Popup>
|
||||||
|
<div style={{ fontSize: 13, lineHeight: 1.5 }}>
|
||||||
|
<strong>{style.label}</strong>
|
||||||
|
<br />
|
||||||
|
{poi.name ?? <em>без названия</em>}
|
||||||
|
<br />
|
||||||
|
{poi.distance_m.toFixed(0)} м
|
||||||
|
{poi.last_edit && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span style={{ color: "#6b7280", fontSize: 11 }}>
|
||||||
|
OSM: {poi.last_edit}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Popup>
|
||||||
|
</CircleMarker>
|
||||||
|
));
|
||||||
|
})}
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@ export interface ParcelAnalysisPoi {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
distance_m: number;
|
distance_m: number;
|
||||||
last_edit: string | null;
|
last_edit: string | null;
|
||||||
|
lat: number;
|
||||||
|
lon: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParcelAnalysis {
|
export interface ParcelAnalysis {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue