feat: водоёмы в зоне поиска на карте (рекомендации профилей → геослой)
- GET /api/v1/water/{case_id}: полигоны водоёмов/болот OSM вокруг ТНП
(Overpass, natural=water+wetland, кэш 72ч, circuit breaker от geo_service);
бейдж-предупреждение по профилям случая (РАС/эпилепсия/не умеет плавать)
- geo_service: цепочка зеркал Overpass (maps.mail.ru → overpass-api.de):
основной сервер имеет AAAA, на хостах без IPv6-маршрута httpx падал
ConnectError'ом до IPv4-фолбэка; на CT108 фапало стабильно
- SearchMap: слой водоёмов под зонами (синие полигоны, болота пунктиром),
попапы с площадью/дистанцией от ТНП; AnalysisResult грузит слой,
показывает предупреждение профиля и счётчик водоёмов
- проверено на CT108: РАС-кейс (Минск) — 43 объекта, витя-кейс
(Каменец-борисовская обл.) — 7 объектов, включая водохранилище Загацце
This commit is contained in:
@@ -11,7 +11,7 @@ L.Icon.Default.mergeOptions({
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
});
|
||||
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones, waterFeatures }) => {
|
||||
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
|
||||
const distance = maxDistance || 5.0;
|
||||
|
||||
@@ -127,6 +127,30 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
}
|
||||
];
|
||||
|
||||
// Слой водоёмов: GeoJSON-полигоны из /api/v1/water/{case_id}.
|
||||
// Рисуется ПОД зонами (передаётся первым в JSX), поверх тайлов OSM.
|
||||
const waterGeoJSON = (waterFeatures || []).map((f) => {
|
||||
// GeoJSON coords [lon, lat] → Leaflet [lat, lon]; Polygon/MultiPolygon.
|
||||
const toLatLngs = (coords) => coords[0]?.[0]?.length === 2 && typeof coords[0][0] === 'number'
|
||||
? coords[0].map((c) => [c[1], c[0]]) // Polygon: rings
|
||||
: coords.map((poly) => poly[0].map((c) => [c[1], c[0]])); // MultiPolygon
|
||||
const positions = waterFeatures._geomCache && waterFeatures._geomCache[waterFeatures.indexOf(f)]
|
||||
? waterFeatures._geomCache[waterFeatures.indexOf(f)]
|
||||
: toLatLngs(f.geom.coordinates);
|
||||
return { feature: f, positions };
|
||||
});
|
||||
|
||||
const waterStyle = (kind) => kind === 'wetland'
|
||||
? { color: '#4a6a8a', fillColor: '#4a6a8a', fillOpacity: 0.25, weight: 1, dashArray: '4 3' }
|
||||
: { color: '#2f81f7', fillColor: '#2f81f7', fillOpacity: 0.35, weight: 1.5 };
|
||||
|
||||
const fmtArea = (m2) => {
|
||||
if (m2 == null) return '';
|
||||
if (m2 >= 1e6) return `${(m2 / 1e6).toFixed(1)} км²`;
|
||||
return `${Math.round(m2 / 1000)} тыс. м²`;
|
||||
};
|
||||
const fmtDist = (m) => (m == null ? '' : m < 1000 ? `${m} м` : `${(m / 1000).toFixed(1)} км`);
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={tnpPosition}
|
||||
@@ -134,10 +158,26 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
style={{ width: '100%', height: '500px', borderRadius: '8px' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution=""
|
||||
attribution="© OpenStreetMap contributors"
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{/* Слой водоёмов (под зонами) */}
|
||||
{waterGeoJSON.map(({ feature, positions }, idx) => (
|
||||
<Polygon
|
||||
key={`water-${idx}`}
|
||||
positions={positions}
|
||||
pathOptions={waterStyle(feature.kind)}
|
||||
>
|
||||
<Popup>
|
||||
<strong>{feature.name || (feature.kind === 'wetland' ? 'Болото' : 'Водоём')}</strong><br/>
|
||||
Тип: {feature.kind === 'wetland' ? 'болото/влажная зона' : 'водоём'}<br/>
|
||||
{feature.area_m2 != null && <>Площадь: ~{fmtArea(feature.area_m2)}<br/></>}
|
||||
{feature.distance_m != null && <>От ТНП: ~{fmtDist(feature.distance_m)}</>}
|
||||
</Popup>
|
||||
</Polygon>
|
||||
))}
|
||||
|
||||
{/* Рисуем секторы или круги */}
|
||||
{sectors.length > 0 ? (
|
||||
sectors.map((sector, idx) => (
|
||||
@@ -201,4 +241,4 @@ const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchMap;
|
||||
export default SearchMap;
|
||||
@@ -33,6 +33,7 @@ const AnalysisResult = () => {
|
||||
const [error, setError] = useState(null);
|
||||
const [caseData, setCaseData] = useState(null);
|
||||
const [analysisData, setAnalysisData] = useState(null);
|
||||
const [waterData, setWaterData] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCaseAndAnalyze = async () => {
|
||||
@@ -55,6 +56,14 @@ const AnalysisResult = () => {
|
||||
const analysis = await analyzeResponse.json();
|
||||
setAnalysisData(analysis);
|
||||
|
||||
// 3. Водоёмы вокруг ТНП (слой на карте; 404/409 — нет координат, не ошибка)
|
||||
try {
|
||||
const waterResponse = await apiFetch(`/api/v1/water/${caseId}`);
|
||||
if (waterResponse.ok) setWaterData(await waterResponse.json());
|
||||
} catch {
|
||||
/* водоёмы — опциональный слой, не блокируют анализ */
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error("Error loading case:", err);
|
||||
setError(err.message);
|
||||
@@ -262,10 +271,24 @@ const AnalysisResult = () => {
|
||||
{tnpLat && tnpLon && (
|
||||
<div className="map-section">
|
||||
<h2>Карта зон поиска</h2>
|
||||
{waterData?.warning && (
|
||||
<div style={{ marginBottom: '10px', padding: '10px', background: '#2d1b0e', border: '1px solid #ff4d00', borderRadius: '6px' }}>
|
||||
<p style={{ margin: 0, fontSize: '13px', color: '#ff4d00' }}>
|
||||
🚨 {waterData.warning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{waterData?.count > 0 && (
|
||||
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#8b949e' }}>
|
||||
💧 Водоёмы в зоне поиска: {waterData.count}
|
||||
{waterData.warning && ' — приоритетная проверка'} (данные OSM)
|
||||
</p>
|
||||
)}
|
||||
<SearchMap zones={zones}
|
||||
maxDistance={parseFloat(analysisData?.max_distance_km)}
|
||||
tnpLat={parseFloat(tnpLat)}
|
||||
tnpLon={parseFloat(tnpLon)}
|
||||
waterFeatures={waterData?.features || []}
|
||||
/>
|
||||
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
|
||||
<p>Координаты ТНП: {tnpLat}, {tnpLon}</p>
|
||||
|
||||
Reference in New Issue
Block a user