2259 lines
92 KiB
React
2259 lines
92 KiB
React
import { useEffect, useRef, useCallback } from 'react';
|
||
import L from 'leaflet';
|
||
import { useShallow } from 'zustand/react/shallow';
|
||
import 'leaflet/dist/leaflet.css';
|
||
import './App.css';
|
||
import LoginPage from './LoginPage';
|
||
import AppLogPanel from './components/app/AppLogPanel';
|
||
import AppMapWorkspace from './components/app/AppMapWorkspace';
|
||
import AppOverlays from './components/app/AppOverlays';
|
||
import AppSidePanel from './components/app/AppSidePanel';
|
||
import AppStatusHeader from './components/app/AppStatusHeader';
|
||
import { useI18n } from './i18n/I18nContext';
|
||
import apiClient from './api/client';
|
||
import { getSbasInsarProductAssetUrl } from './api/sbasInsarProducts';
|
||
import {
|
||
useAuthStore, useTaskStore, useUiStore, useRadarStore,
|
||
useDinsarStore, useBatchStore, usePairingStore, useHazardStore, useMapStore,
|
||
} from './store';
|
||
import useAppAuthLifecycle from './hooks/useAppAuthLifecycle';
|
||
import useGlobalTaskControl from './hooks/useGlobalTaskControl';
|
||
import usePanelResize from './hooks/usePanelResize';
|
||
import useRegionAoiHandlers from './hooks/useRegionAoiHandlers';
|
||
import usePaginationControls from './hooks/usePaginationControls';
|
||
import useRadarSearch from './hooks/useRadarSearch';
|
||
import useBatchOperations from './hooks/useBatchOperations';
|
||
import useDinsarOperations from './hooks/useDinsarOperations';
|
||
import usePairingLogic from './hooks/usePairingLogic';
|
||
import useMapExport from './hooks/useMapExport';
|
||
import {
|
||
TILE_LAYER_OPTIONS,
|
||
TILE_LAYER_BOUNDS,
|
||
NATIONAL_BOUNDARY_GEOJSON_URL,
|
||
getBaseLayerConfig,
|
||
DEFAULT_LIST_PAGE_SIZE,
|
||
FULL_WIDTH_LEFT_TABS,
|
||
} from './config/appConstants';
|
||
import { escapeHtml, formatCoordinate } from './utils/appHelpers';
|
||
import {
|
||
normalizePreviewStatus,
|
||
formatYmd,
|
||
parseDatesFromName,
|
||
} from './utils/appUiHelpers';
|
||
import {
|
||
DINSAR_STRATEGY_ALL,
|
||
filterDinsarResults,
|
||
} from './utils/dinsarResultFilters';
|
||
import { DINSAR_ENGINE_ALL, getDinsarEngineMeta } from './utils/dinsarEngines';
|
||
|
||
const NATIONAL_BOUNDARY_STATIC_URL = '/geojson/\u5168\u56fd\u884c\u653f\u533a.geojson';
|
||
|
||
const toFiniteNumber = (value) => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric : null;
|
||
};
|
||
|
||
const formatMapNumber = (value, digits = 2) => {
|
||
const numeric = Number(value);
|
||
return Number.isFinite(numeric) ? numeric.toFixed(digits) : '-';
|
||
};
|
||
|
||
const getSbasProductBounds = (product) => {
|
||
const coverage = product?.geographic_coverage || {};
|
||
const bbox = coverage.bbox || {};
|
||
const minLon = toFiniteNumber(product?.min_lon ?? bbox.min_lon);
|
||
const minLat = toFiniteNumber(product?.min_lat ?? bbox.min_lat);
|
||
const maxLon = toFiniteNumber(product?.max_lon ?? bbox.max_lon);
|
||
const maxLat = toFiniteNumber(product?.max_lat ?? bbox.max_lat);
|
||
if ([minLon, minLat, maxLon, maxLat].some(value => value === null)) return null;
|
||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||
return [[minLat, minLon], [maxLat, maxLon]];
|
||
};
|
||
|
||
const findSbasAsset = (detail, roles) => {
|
||
const roleSet = new Set(roles);
|
||
return (detail?.assets || []).find(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||
};
|
||
|
||
const sbasAssetCacheKey = (asset) => (
|
||
[asset?.id, asset?.file_size, asset?.updated_at || asset?.created_at || asset?.relative_path]
|
||
.filter(Boolean)
|
||
.join(':')
|
||
);
|
||
|
||
const sbasRateColor = (rate) => {
|
||
const numeric = Number(rate);
|
||
if (!Number.isFinite(numeric)) return '#64748b';
|
||
if (numeric <= -30) return '#1d4ed8';
|
||
if (numeric < -5) return '#38bdf8';
|
||
if (numeric <= 5) return '#16a34a';
|
||
if (numeric < 30) return '#f59e0b';
|
||
return '#dc2626';
|
||
};
|
||
|
||
const SBAS_OVERVIEW_COLORS = ['#1d4ed8', '#dc2626', '#059669', '#7c3aed', '#d97706', '#0f766e'];
|
||
|
||
const normalizeSbasDisplacements = (rows) => (Array.isArray(rows) ? rows : [])
|
||
.map((item) => {
|
||
const date = String(item?.date || '').trim();
|
||
const time = Date.parse(`${date}T00:00:00Z`);
|
||
const displacement = Number(item?.displacement_mm ?? item?.displacement ?? item?.value);
|
||
if (!date || !Number.isFinite(time) || !Number.isFinite(displacement)) return null;
|
||
return { date, time, displacement };
|
||
})
|
||
.filter(Boolean)
|
||
.sort((left, right) => left.time - right.time);
|
||
|
||
const buildSbasSparklineSvg = (rows) => {
|
||
const values = normalizeSbasDisplacements(rows);
|
||
if (values.length < 2) return '';
|
||
const width = 220;
|
||
const height = 72;
|
||
const padX = 12;
|
||
const padY = 10;
|
||
const minTime = Math.min(...values.map(item => item.time));
|
||
const maxTime = Math.max(...values.map(item => item.time));
|
||
const minValue = Math.min(...values.map(item => item.displacement), 0);
|
||
const maxValue = Math.max(...values.map(item => item.displacement), 0);
|
||
const timeSpan = Math.max(1, maxTime - minTime);
|
||
const valueSpan = Math.max(1e-9, maxValue - minValue);
|
||
const xScale = (time) => padX + ((time - minTime) / timeSpan) * (width - padX * 2);
|
||
const yScale = (value) => height - padY - ((value - minValue) / valueSpan) * (height - padY * 2);
|
||
const points = values.map(item => `${xScale(item.time).toFixed(1)},${yScale(item.displacement).toFixed(1)}`).join(' ');
|
||
const zeroY = yScale(0).toFixed(1);
|
||
const minLabel = escapeHtml(formatMapNumber(minValue, 1));
|
||
const maxLabel = escapeHtml(formatMapNumber(maxValue, 1));
|
||
return `
|
||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="SBAS displacement sparkline">
|
||
<rect x="0" y="0" width="${width}" height="${height}" fill="#f8fafc" rx="6"></rect>
|
||
<line x1="${padX}" x2="${width - padX}" y1="${zeroY}" y2="${zeroY}" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3 3"></line>
|
||
<polyline points="${points}" fill="none" stroke="#1d4ed8" stroke-width="2"></polyline>
|
||
${values.map(item => `<circle cx="${xScale(item.time).toFixed(1)}" cy="${yScale(item.displacement).toFixed(1)}" r="2.4" fill="#1d4ed8"></circle>`).join('')}
|
||
<text x="${padX}" y="10" font-size="9" fill="#64748b">${maxLabel} mm</text>
|
||
<text x="${padX}" y="${height - 4}" font-size="9" fill="#64748b">${minLabel} mm</text>
|
||
</svg>
|
||
`;
|
||
};
|
||
|
||
const buildSbasPointPopupHtml = (point, options = {}) => {
|
||
const matched = point?.matched || {};
|
||
const pointId = escapeHtml(point?.point_id || options.pointId || 'SBAS point');
|
||
const label = escapeHtml(point?.selection_label || options.label || pointId);
|
||
const rate = point?.deformation_rate_mm_per_year ?? point?.los_rate_mm_per_year ?? matched.los_rate_mm_per_year;
|
||
const lon = point?.lon ?? matched.lon;
|
||
const lat = point?.lat ?? matched.lat;
|
||
const nearestNote = matched.used_nearest
|
||
? `<div><strong>匹配:</strong> 最近有效像元,距离 ${escapeHtml(formatMapNumber(matched.distance_m, 1))} m</div>`
|
||
: '';
|
||
return `
|
||
<div class="sbas-popup" style="min-width:240px">
|
||
<div style="font-weight:800;margin-bottom:6px">${label}</div>
|
||
<div><strong>ID:</strong> <span class="mono">${pointId}</span></div>
|
||
<div><strong>经纬度:</strong> ${escapeHtml(formatMapNumber(lon, 6))}, ${escapeHtml(formatMapNumber(lat, 6))}</div>
|
||
<div><strong>LOS速率:</strong> ${escapeHtml(formatMapNumber(rate, 2))} mm/yr</div>
|
||
${nearestNote}
|
||
<div style="margin-top:8px">${buildSbasSparklineSvg(point?.displacements || options.displacements || [])}</div>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
const buildSbasOverviewPopupHtml = (product) => {
|
||
const title = escapeHtml(product?.display_name || product?.stack_key || product?.run_key || `SBAS #${product?.id ?? '-'}`);
|
||
const dateStart = escapeHtml(String(product?.date_start || '-').slice(0, 10));
|
||
const dateEnd = escapeHtml(String(product?.date_end || '-').slice(0, 10));
|
||
const stackSize = escapeHtml(product?.stack_size ?? product?.stack_dates?.length ?? '-');
|
||
const status = escapeHtml(product?.status || '-');
|
||
const health = escapeHtml(product?.health_status || '-');
|
||
const runKey = escapeHtml(product?.run_key || '-');
|
||
const stackKey = escapeHtml(product?.stack_key || '-');
|
||
const region = product?.admin_region?.display_name || product?.admin_region?.name || product?.admin_region?.tree_id || '-';
|
||
return `
|
||
<div class="sbas-popup" style="min-width:260px">
|
||
<div style="font-weight:850;margin-bottom:7px">${title}</div>
|
||
<div><strong>时间:</strong> ${dateStart} → ${dateEnd}</div>
|
||
<div><strong>栈期数:</strong> ${stackSize}</div>
|
||
<div><strong>状态:</strong> ${status} / ${health}</div>
|
||
<div><strong>区域:</strong> ${escapeHtml(region)}</div>
|
||
<div><strong>stack:</strong> <span class="mono">${stackKey}</span></div>
|
||
<div><strong>run:</strong> <span class="mono">${runKey}</span></div>
|
||
</div>
|
||
`;
|
||
};
|
||
|
||
function App() {
|
||
const { language, setLanguage } = useI18n();
|
||
|
||
// --- Zustand stores ---
|
||
const {
|
||
currentUser, setCurrentUser, authChecked, setAuthChecked,
|
||
licenseStatus, setLicenseStatus, licenseLoading, setLicenseLoading,
|
||
licenseUploadStatus, setLicenseUploadStatus, licenseFileName, setLicenseFileName,
|
||
healthStatus, setHealthStatus, healthLoading, setHealthLoading, healthError, setHealthError,
|
||
} = useAuthStore(useShallow((state) => ({
|
||
currentUser: state.currentUser,
|
||
setCurrentUser: state.setCurrentUser,
|
||
authChecked: state.authChecked,
|
||
setAuthChecked: state.setAuthChecked,
|
||
licenseStatus: state.licenseStatus,
|
||
setLicenseStatus: state.setLicenseStatus,
|
||
licenseLoading: state.licenseLoading,
|
||
setLicenseLoading: state.setLicenseLoading,
|
||
licenseUploadStatus: state.licenseUploadStatus,
|
||
setLicenseUploadStatus: state.setLicenseUploadStatus,
|
||
licenseFileName: state.licenseFileName,
|
||
setLicenseFileName: state.setLicenseFileName,
|
||
healthStatus: state.healthStatus,
|
||
setHealthStatus: state.setHealthStatus,
|
||
healthLoading: state.healthLoading,
|
||
setHealthLoading: state.setHealthLoading,
|
||
healthError: state.healthError,
|
||
setHealthError: state.setHealthError,
|
||
})));
|
||
const {
|
||
activeTasks, setActiveTasks,
|
||
isCheckingTasks, setIsCheckingTasks,
|
||
pendingTaskIds, setPendingTaskIds,
|
||
} = useTaskStore(useShallow((state) => ({
|
||
activeTasks: state.activeTasks,
|
||
setActiveTasks: state.setActiveTasks,
|
||
isCheckingTasks: state.isCheckingTasks,
|
||
setIsCheckingTasks: state.setIsCheckingTasks,
|
||
pendingTaskIds: state.pendingTaskIds,
|
||
setPendingTaskIds: state.setPendingTaskIds,
|
||
})));
|
||
const {
|
||
leftPanelTab, setLeftPanelTab, leftPanelWidth, setLeftPanelWidth,
|
||
rightPanelWidth, setRightPanelWidth, isResizing, setIsResizing,
|
||
setShowStats, setShowDataInfo, setSelectedDataInfo, showDates,
|
||
baseLayerKey, setBaseLayerKey, isLoading, setIsLoading, addLog,
|
||
} = useUiStore(useShallow((state) => ({
|
||
leftPanelTab: state.leftPanelTab,
|
||
setLeftPanelTab: state.setLeftPanelTab,
|
||
leftPanelWidth: state.leftPanelWidth,
|
||
setLeftPanelWidth: state.setLeftPanelWidth,
|
||
rightPanelWidth: state.rightPanelWidth,
|
||
setRightPanelWidth: state.setRightPanelWidth,
|
||
isResizing: state.isResizing,
|
||
setIsResizing: state.setIsResizing,
|
||
setShowStats: state.setShowStats,
|
||
setShowDataInfo: state.setShowDataInfo,
|
||
setSelectedDataInfo: state.setSelectedDataInfo,
|
||
showDates: state.showDates,
|
||
baseLayerKey: state.baseLayerKey,
|
||
setBaseLayerKey: state.setBaseLayerKey,
|
||
isLoading: state.isLoading,
|
||
setIsLoading: state.setIsLoading,
|
||
addLog: state.addLog,
|
||
})));
|
||
const {
|
||
allData, setAllData, radarPagination, setRadarPagination,
|
||
radarPageInput, setRadarPageInput, radarPageInputTouched, setRadarPageInputTouched,
|
||
hasRadarSearched, setHasRadarSearched, radarImagingDates, setRadarImagingDates,
|
||
radarSearchDraft, setRadarSearchDraft, radarSearchApplied, setRadarSearchApplied,
|
||
setRadarSearchOptions, setRadarSearchOptionsLoading,
|
||
radarSearchAoiMode, setRadarSearchAoiMode, radarSearchAppliedAoiMode, setRadarSearchAppliedAoiMode,
|
||
radarSearchFiles, setRadarSearchFiles,
|
||
radarSearchRegionOptions, setRadarSearchRegionOptions,
|
||
radarSearchRegionSelection, setRadarSearchRegionSelection,
|
||
radarSearchAppliedRegionTreeId, setRadarSearchAppliedRegionTreeId,
|
||
setRadarSearchRegionLoading, setRadarSearchRegionError,
|
||
radarSearchAoiToken, setRadarSearchAoiToken,
|
||
selectedSatelliteGroup, setSelectedSatelliteGroup,
|
||
rebuildingPreviewIds, setRebuildingPreviewIds,
|
||
} = useRadarStore(useShallow((state) => ({
|
||
allData: state.allData,
|
||
setAllData: state.setAllData,
|
||
radarPagination: state.radarPagination,
|
||
setRadarPagination: state.setRadarPagination,
|
||
radarPageInput: state.radarPageInput,
|
||
setRadarPageInput: state.setRadarPageInput,
|
||
radarPageInputTouched: state.radarPageInputTouched,
|
||
setRadarPageInputTouched: state.setRadarPageInputTouched,
|
||
hasRadarSearched: state.hasRadarSearched,
|
||
setHasRadarSearched: state.setHasRadarSearched,
|
||
radarImagingDates: state.radarImagingDates,
|
||
setRadarImagingDates: state.setRadarImagingDates,
|
||
radarSearchDraft: state.radarSearchDraft,
|
||
setRadarSearchDraft: state.setRadarSearchDraft,
|
||
radarSearchApplied: state.radarSearchApplied,
|
||
setRadarSearchApplied: state.setRadarSearchApplied,
|
||
setRadarSearchOptions: state.setRadarSearchOptions,
|
||
setRadarSearchOptionsLoading: state.setRadarSearchOptionsLoading,
|
||
radarSearchAoiMode: state.radarSearchAoiMode,
|
||
setRadarSearchAoiMode: state.setRadarSearchAoiMode,
|
||
radarSearchAppliedAoiMode: state.radarSearchAppliedAoiMode,
|
||
setRadarSearchAppliedAoiMode: state.setRadarSearchAppliedAoiMode,
|
||
radarSearchFiles: state.radarSearchFiles,
|
||
setRadarSearchFiles: state.setRadarSearchFiles,
|
||
radarSearchRegionOptions: state.radarSearchRegionOptions,
|
||
setRadarSearchRegionOptions: state.setRadarSearchRegionOptions,
|
||
radarSearchRegionSelection: state.radarSearchRegionSelection,
|
||
setRadarSearchRegionSelection: state.setRadarSearchRegionSelection,
|
||
radarSearchAppliedRegionTreeId: state.radarSearchAppliedRegionTreeId,
|
||
setRadarSearchAppliedRegionTreeId: state.setRadarSearchAppliedRegionTreeId,
|
||
setRadarSearchRegionLoading: state.setRadarSearchRegionLoading,
|
||
setRadarSearchRegionError: state.setRadarSearchRegionError,
|
||
radarSearchAoiToken: state.radarSearchAoiToken,
|
||
setRadarSearchAoiToken: state.setRadarSearchAoiToken,
|
||
selectedSatelliteGroup: state.selectedSatelliteGroup,
|
||
setSelectedSatelliteGroup: state.setSelectedSatelliteGroup,
|
||
rebuildingPreviewIds: state.rebuildingPreviewIds,
|
||
setRebuildingPreviewIds: state.setRebuildingPreviewIds,
|
||
})));
|
||
const {
|
||
dinsarResults, setDinsarResults, dinsarPagination,
|
||
dinsarPageInput, setDinsarPageInput, dinsarPageInputTouched, setDinsarPageInputTouched,
|
||
aiStatus, scoreFilter, setScoreFilter, engineFilter, traceSearch, strategyFilter,
|
||
} = useDinsarStore(useShallow((state) => ({
|
||
dinsarResults: state.dinsarResults,
|
||
setDinsarResults: state.setDinsarResults,
|
||
dinsarPagination: state.dinsarPagination,
|
||
dinsarPageInput: state.dinsarPageInput,
|
||
setDinsarPageInput: state.setDinsarPageInput,
|
||
dinsarPageInputTouched: state.dinsarPageInputTouched,
|
||
setDinsarPageInputTouched: state.setDinsarPageInputTouched,
|
||
aiStatus: state.aiStatus,
|
||
scoreFilter: state.scoreFilter,
|
||
setScoreFilter: state.setScoreFilter,
|
||
engineFilter: state.engineFilter,
|
||
traceSearch: state.traceSearch,
|
||
strategyFilter: state.strategyFilter,
|
||
})));
|
||
const {
|
||
batchTab, setDinsarBatches, setPsBatches,
|
||
selectedBatchId, setBatchItems,
|
||
setBatchLoading, setBatchError,
|
||
} = useBatchStore(useShallow((state) => ({
|
||
batchTab: state.batchTab,
|
||
setDinsarBatches: state.setDinsarBatches,
|
||
setPsBatches: state.setPsBatches,
|
||
selectedBatchId: state.selectedBatchId,
|
||
setBatchItems: state.setBatchItems,
|
||
setBatchLoading: state.setBatchLoading,
|
||
setBatchError: state.setBatchError,
|
||
})));
|
||
const {
|
||
foundPairs, setFoundPairs,
|
||
setShowPairingModal,
|
||
pairingAoiMode, setPairingAoiMode,
|
||
pairingRegionOptions, setPairingRegionOptions,
|
||
setPairingRegionSelection,
|
||
setPairingRegionLoading, setPairingRegionError,
|
||
setShowPsModal,
|
||
psAoiMode, setPsAoiMode,
|
||
psRegionOptions, setPsRegionOptions,
|
||
setPsRegionSelection,
|
||
setPsRegionLoading, setPsRegionError,
|
||
} = usePairingStore(useShallow((state) => ({
|
||
foundPairs: state.foundPairs,
|
||
setFoundPairs: state.setFoundPairs,
|
||
setShowPairingModal: state.setShowPairingModal,
|
||
pairingAoiMode: state.pairingAoiMode,
|
||
setPairingAoiMode: state.setPairingAoiMode,
|
||
pairingRegionOptions: state.pairingRegionOptions,
|
||
setPairingRegionOptions: state.setPairingRegionOptions,
|
||
setPairingRegionSelection: state.setPairingRegionSelection,
|
||
setPairingRegionLoading: state.setPairingRegionLoading,
|
||
setPairingRegionError: state.setPairingRegionError,
|
||
setShowPsModal: state.setShowPsModal,
|
||
psAoiMode: state.psAoiMode,
|
||
setPsAoiMode: state.setPsAoiMode,
|
||
psRegionOptions: state.psRegionOptions,
|
||
setPsRegionOptions: state.setPsRegionOptions,
|
||
setPsRegionSelection: state.setPsRegionSelection,
|
||
setPsRegionLoading: state.setPsRegionLoading,
|
||
setPsRegionError: state.setPsRegionError,
|
||
})));
|
||
const {
|
||
hazardPoints, showHazardPoints, setShowHazardPoints,
|
||
focusedHazardPoint, setFocusedHazardPoint,
|
||
} = useHazardStore(useShallow((state) => ({
|
||
hazardPoints: state.hazardPoints,
|
||
showHazardPoints: state.showHazardPoints,
|
||
setShowHazardPoints: state.setShowHazardPoints,
|
||
focusedHazardPoint: state.focusedHazardPoint,
|
||
setFocusedHazardPoint: state.setFocusedHazardPoint,
|
||
})));
|
||
const {
|
||
aoiLayer, setAoiLayer, showMapRegionLocator, setShowMapRegionLocator,
|
||
mapRegionOptions, setMapRegionOptions, mapRegionSelection, setMapRegionSelection,
|
||
mapRegionLoading, setMapRegionLoading, mapRegionLocating, setMapRegionLocating,
|
||
mapRegionError, setMapRegionError, mapRegionLocatedName, setMapRegionLocatedName,
|
||
} = useMapStore(useShallow((state) => ({
|
||
aoiLayer: state.aoiLayer,
|
||
setAoiLayer: state.setAoiLayer,
|
||
showMapRegionLocator: state.showMapRegionLocator,
|
||
setShowMapRegionLocator: state.setShowMapRegionLocator,
|
||
mapRegionOptions: state.mapRegionOptions,
|
||
setMapRegionOptions: state.setMapRegionOptions,
|
||
mapRegionSelection: state.mapRegionSelection,
|
||
setMapRegionSelection: state.setMapRegionSelection,
|
||
mapRegionLoading: state.mapRegionLoading,
|
||
setMapRegionLoading: state.setMapRegionLoading,
|
||
mapRegionLocating: state.mapRegionLocating,
|
||
setMapRegionLocating: state.setMapRegionLocating,
|
||
mapRegionError: state.mapRegionError,
|
||
setMapRegionError: state.setMapRegionError,
|
||
mapRegionLocatedName: state.mapRegionLocatedName,
|
||
setMapRegionLocatedName: state.setMapRegionLocatedName,
|
||
})));
|
||
|
||
const mapRef = useRef(null);
|
||
const tileLayerRef = useRef(null);
|
||
const activeLayersRef = useRef({});
|
||
const radarPreviewLayersRef = useRef({});
|
||
const pairLayersRef = useRef({});
|
||
const psStackPreviewLayerRef = useRef(null);
|
||
const psStackPreviewStateRef = useRef({ previousVisibilityById: new Map() });
|
||
const hazardLayersGroupRef = useRef(null);
|
||
const aoeLayerRef = useRef(null);
|
||
const waterSceneLayersRef = useRef({});
|
||
const floodEventLayersRef = useRef({}); // { eventId: { pre, post, classified } }
|
||
const floodPairPreviewLayerRef = useRef(null);
|
||
const floodVectorLayersRef = useRef({});
|
||
const mapRegionLayerRef = useRef(null);
|
||
const prevLicenseOkRef = useRef(false);
|
||
const initializeAppDataRef = useRef(null);
|
||
const addLogRef = useRef(addLog);
|
||
const handleTaskCompletionRef = useRef(null);
|
||
const updateLayerTooltipRef = useRef(null);
|
||
const radarSearchRequestSeqRef = useRef(0);
|
||
const licenseFileRef = useRef(null);
|
||
const foundPairsRef = useRef(foundPairs);
|
||
const hazardLayersRef = useRef({});
|
||
const dinsarResultLayersRef = useRef({});
|
||
const sbasAnalysisLayersRef = useRef({});
|
||
const resizeStateRef = useRef({ side: null, startX: 0, startLeft: 0, startRight: 0 });
|
||
const allDataRef = useRef(allData);
|
||
const dinsarResultsRef = useRef(dinsarResults);
|
||
const mapBatchRef = useRef({ frameId: null, token: 0 });
|
||
const isAdmin = currentUser?.role === 'admin';
|
||
const isReadOnlyUser = !!currentUser && !isAdmin;
|
||
const isStandaloneLeftPage = FULL_WIDTH_LEFT_TABS.has(leftPanelTab);
|
||
|
||
useEffect(() => {
|
||
if (isStandaloneLeftPage || !mapRef.current) {
|
||
return undefined;
|
||
}
|
||
|
||
const invalidateMap = () => {
|
||
mapRef.current?.invalidateSize(false);
|
||
};
|
||
const frameId = requestAnimationFrame(invalidateMap);
|
||
const timeoutId = window.setTimeout(invalidateMap, 120);
|
||
|
||
return () => {
|
||
cancelAnimationFrame(frameId);
|
||
window.clearTimeout(timeoutId);
|
||
};
|
||
}, [isStandaloneLeftPage, leftPanelWidth, rightPanelWidth]);
|
||
|
||
const getVisibleLayerRefs = useCallback(() => ({
|
||
activeLayersRef: activeLayersRef.current,
|
||
hazardLayersGroupRef: hazardLayersGroupRef.current,
|
||
dinsarResultLayersRef: dinsarResultLayersRef.current,
|
||
sbasAnalysisLayersRef: sbasAnalysisLayersRef.current,
|
||
waterSceneLayersRef: waterSceneLayersRef.current,
|
||
radarPreviewLayersRef: radarPreviewLayersRef.current,
|
||
pairLayersRef: pairLayersRef.current,
|
||
aoeLayerRef: aoeLayerRef.current,
|
||
floodEventLayersRef: floodEventLayersRef.current,
|
||
floodVectorLayersRef: floodVectorLayersRef.current,
|
||
}), []);
|
||
|
||
const mapExport = useMapExport({ mapRef, getVisibleLayerRefs, addLog, language });
|
||
|
||
const cancelMapBatch = useCallback(() => {
|
||
if (mapBatchRef.current.frameId !== null) {
|
||
cancelAnimationFrame(mapBatchRef.current.frameId);
|
||
mapBatchRef.current.frameId = null;
|
||
}
|
||
mapBatchRef.current.token += 1;
|
||
}, []);
|
||
|
||
const runMapBatch = useCallback((items, processItem, onComplete) => {
|
||
cancelMapBatch();
|
||
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
onComplete?.();
|
||
return;
|
||
}
|
||
|
||
const token = mapBatchRef.current.token;
|
||
let cursor = 0;
|
||
|
||
const flushFrame = () => {
|
||
if (token !== mapBatchRef.current.token || !mapRef.current) {
|
||
return;
|
||
}
|
||
|
||
const frameStart = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||
while (cursor < items.length) {
|
||
processItem(items[cursor], cursor);
|
||
cursor += 1;
|
||
|
||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
||
if ((now - frameStart) >= 8) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (cursor < items.length) {
|
||
mapBatchRef.current.frameId = requestAnimationFrame(flushFrame);
|
||
return;
|
||
}
|
||
|
||
mapBatchRef.current.frameId = null;
|
||
onComplete?.();
|
||
};
|
||
|
||
mapBatchRef.current.frameId = requestAnimationFrame(flushFrame);
|
||
}, [cancelMapBatch]);
|
||
|
||
const ensureCanOperate = useCallback(() => {
|
||
if (!isAdmin) {
|
||
addLog('warn', '当前账号为只读用户,无法执行写操作。');
|
||
return false;
|
||
}
|
||
return true;
|
||
}, [addLog, isAdmin]);
|
||
|
||
const clearRadarMapLayers = () => {
|
||
cancelMapBatch();
|
||
if (aoeLayerRef.current) {
|
||
aoeLayerRef.current.remove();
|
||
aoeLayerRef.current = null;
|
||
}
|
||
if (psStackPreviewLayerRef.current) {
|
||
psStackPreviewLayerRef.current.remove();
|
||
psStackPreviewLayerRef.current = null;
|
||
}
|
||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||
setAoiLayer(null);
|
||
|
||
Object.values(activeLayersRef.current).forEach(layer => layer.remove());
|
||
activeLayersRef.current = {};
|
||
Object.values(radarPreviewLayersRef.current).forEach(layer => layer.remove());
|
||
radarPreviewLayersRef.current = {};
|
||
};
|
||
|
||
const clearSbasAnalysisLayers = useCallback(() => {
|
||
Object.values(sbasAnalysisLayersRef.current).forEach((entry) => {
|
||
const layer = entry?.layer || entry;
|
||
if (layer?.remove) {
|
||
layer.remove();
|
||
}
|
||
});
|
||
sbasAnalysisLayersRef.current = {};
|
||
}, []);
|
||
|
||
const clearRadarSearchResults = (options = {}) => {
|
||
const nextLimit = Math.max(
|
||
1,
|
||
Math.min(Number(options.limit ?? radarPagination.limit ?? DEFAULT_LIST_PAGE_SIZE) || DEFAULT_LIST_PAGE_SIZE, 2000)
|
||
);
|
||
clearRadarMapLayers();
|
||
allDataRef.current = [];
|
||
setAllData([]);
|
||
setRadarPagination({
|
||
total: 0,
|
||
limit: nextLimit,
|
||
offset: 0,
|
||
hasMore: false,
|
||
});
|
||
};
|
||
|
||
const {
|
||
fetchRadarImagingDates,
|
||
fetchRadarSearchOptions,
|
||
fetchAllData,
|
||
applyRadarSearch,
|
||
resetRadarSearch,
|
||
searchAllRadarData,
|
||
refreshCurrentRadarSearch,
|
||
changeSatelliteGroup,
|
||
} = useRadarSearch({
|
||
addLog,
|
||
setIsLoading,
|
||
setAllData,
|
||
setRadarImagingDates,
|
||
setRadarSearchOptions,
|
||
setRadarSearchOptionsLoading,
|
||
radarPagination,
|
||
setRadarPagination,
|
||
radarSearchDraft,
|
||
setRadarSearchDraft,
|
||
radarSearchApplied,
|
||
setRadarSearchApplied,
|
||
radarSearchAoiMode,
|
||
setRadarSearchAoiMode,
|
||
setRadarSearchAppliedAoiMode,
|
||
radarSearchAppliedAoiMode,
|
||
radarSearchFiles,
|
||
setRadarSearchFiles,
|
||
setRadarSearchRegionOptions,
|
||
radarSearchRegionSelection,
|
||
setRadarSearchRegionSelection,
|
||
radarSearchAppliedRegionTreeId,
|
||
setRadarSearchAppliedRegionTreeId,
|
||
setRadarSearchRegionError,
|
||
radarSearchAoiToken,
|
||
setRadarSearchAoiToken,
|
||
hasRadarSearched,
|
||
setHasRadarSearched,
|
||
selectedSatelliteGroup,
|
||
setSelectedSatelliteGroup,
|
||
radarSearchRequestSeqRef,
|
||
clearRadarSearchResults,
|
||
clearRadarMapLayers,
|
||
});
|
||
|
||
const {
|
||
refreshBatchList,
|
||
fetchBatchItems,
|
||
} = useBatchOperations({
|
||
addLog,
|
||
ensureCanOperate,
|
||
batchTab,
|
||
selectedBatchId,
|
||
setDinsarBatches,
|
||
setPsBatches,
|
||
setBatchItems,
|
||
setBatchLoading,
|
||
setBatchError,
|
||
});
|
||
|
||
useEffect(() => {
|
||
addLogRef.current = addLog;
|
||
}, [addLog]);
|
||
|
||
useEffect(() => {
|
||
allDataRef.current = allData;
|
||
}, [allData]);
|
||
|
||
useEffect(() => {
|
||
dinsarResultsRef.current = dinsarResults;
|
||
}, [dinsarResults]);
|
||
|
||
useEffect(() => {
|
||
foundPairsRef.current = foundPairs;
|
||
}, [foundPairs]);
|
||
|
||
useEffect(() => () => {
|
||
cancelMapBatch();
|
||
}, [cancelMapBatch]);
|
||
|
||
const applyPreviewStatus = useCallback((itemId, payload) => {
|
||
if (!payload) return;
|
||
setAllData(prev => prev.map(row => (
|
||
row.id === itemId
|
||
? {
|
||
...row,
|
||
previewStatus: normalizePreviewStatus(payload.status),
|
||
previewFallbackInUse: !!payload.fallback_in_use,
|
||
previewHasGeoCache: !!payload.has_geo_cache,
|
||
previewHasRawCache: !!payload.has_raw_cache,
|
||
previewSourceFound: !!payload.source_found,
|
||
previewMessage: payload.message || '',
|
||
previewError: payload.error || '',
|
||
previewCacheKey: payload.cache_updated_at || row.previewCacheKey || `${Date.now()}-${itemId}`,
|
||
}
|
||
: row
|
||
)));
|
||
}, [setAllData]);
|
||
|
||
const {
|
||
fetchRegionGeometry,
|
||
handlePairingAoiModeChange,
|
||
handlePairingProvinceChange,
|
||
handlePairingCityChange,
|
||
toggleMapRegionLocator,
|
||
handleMapRegionProvinceChange,
|
||
handleMapRegionCityChange,
|
||
handleRadarSearchAoiModeChange,
|
||
handleRadarSearchProvinceChange,
|
||
handleRadarSearchCityChange,
|
||
updateRadarSearchDraft,
|
||
locateSelectedRegionOnMap,
|
||
clearMapRegionHighlight,
|
||
openPairingModal,
|
||
} = useRegionAoiHandlers({
|
||
setPairingRegionLoading,
|
||
setPairingRegionError,
|
||
setPairingRegionOptions,
|
||
setPsRegionLoading,
|
||
setPsRegionError,
|
||
setPsRegionOptions,
|
||
setPairingAoiMode,
|
||
pairingRegionOptions,
|
||
setPsAoiMode,
|
||
psRegionOptions,
|
||
setPairingRegionSelection,
|
||
setPsRegionSelection,
|
||
setMapRegionLoading,
|
||
setMapRegionError,
|
||
setMapRegionOptions,
|
||
showMapRegionLocator,
|
||
setShowMapRegionLocator,
|
||
mapRegionOptions,
|
||
setMapRegionSelection,
|
||
setMapRegionLocatedName,
|
||
setMapRegionLocating,
|
||
setRadarSearchRegionLoading,
|
||
setRadarSearchRegionError,
|
||
setRadarSearchRegionOptions,
|
||
setRadarSearchAoiMode,
|
||
setRadarSearchFiles,
|
||
setRadarSearchRegionSelection,
|
||
radarSearchRegionOptions,
|
||
setRadarSearchDraft,
|
||
mapRef,
|
||
mapRegionLayerRef,
|
||
mapRegionSelection,
|
||
addLog,
|
||
pairingAoiMode,
|
||
setShowPairingModal,
|
||
psAoiMode,
|
||
setShowPsModal,
|
||
});
|
||
|
||
const { startResize } = usePanelResize({
|
||
isResizing,
|
||
setIsResizing,
|
||
leftPanelWidth,
|
||
rightPanelWidth,
|
||
setLeftPanelWidth,
|
||
setRightPanelWidth,
|
||
resizeStateRef,
|
||
});
|
||
|
||
const {
|
||
handleLoginSuccess,
|
||
handleLogout,
|
||
fetchLicenseStatus,
|
||
fetchHealthStatus,
|
||
handleLicenseUpload,
|
||
} = useAppAuthLifecycle({
|
||
ensureCanOperate,
|
||
clearRadarSearchResults,
|
||
radarSearchRequestSeqRef,
|
||
prevLicenseOkRef,
|
||
aoeLayerRef,
|
||
activeLayersRef,
|
||
radarPreviewLayersRef,
|
||
setHasRadarSearched,
|
||
setCurrentUser,
|
||
setAuthChecked,
|
||
setPendingTaskIds,
|
||
setLicenseLoading,
|
||
setLicenseStatus,
|
||
setHealthLoading,
|
||
setHealthError,
|
||
setHealthStatus,
|
||
setLicenseFileName,
|
||
setLicenseUploadStatus,
|
||
setAoiLayer,
|
||
setAllData,
|
||
setRadarPagination,
|
||
});
|
||
|
||
// --- Refactored Initialization ---
|
||
useEffect(() => {
|
||
if (!currentUser?.id) {
|
||
return;
|
||
}
|
||
let isDisposed = false;
|
||
|
||
addLogRef.current('info', '应用初始化...');
|
||
mapRef.current = L.map('map', { zoomControl: false });
|
||
|
||
// 底图图层将在 baseLayerKey effect 中加载
|
||
|
||
// 设置地图初始视图以匹配切片范围
|
||
if (TILE_LAYER_BOUNDS) {
|
||
mapRef.current.fitBounds(TILE_LAYER_BOUNDS);
|
||
}
|
||
|
||
const loadNationalBoundary = async () => {
|
||
const fetchBoundaryGeoJson = async url => {
|
||
const response = await fetch(url);
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}`);
|
||
}
|
||
return response.json();
|
||
};
|
||
|
||
try {
|
||
let geojsonData;
|
||
let loadedFromFallback = false;
|
||
|
||
try {
|
||
geojsonData = await fetchBoundaryGeoJson(NATIONAL_BOUNDARY_GEOJSON_URL);
|
||
} catch (primaryError) {
|
||
console.warn('全国行政区边界远程加载失败,尝试本地静态文件兜底。', primaryError);
|
||
geojsonData = await fetchBoundaryGeoJson(NATIONAL_BOUNDARY_STATIC_URL);
|
||
loadedFromFallback = true;
|
||
}
|
||
|
||
if (isDisposed || !mapRef.current) return;
|
||
L.geoJSON(geojsonData, {
|
||
style: { color: "#64748b", weight: 1, opacity: 0.35, fillOpacity: 0 },
|
||
interactive: false,
|
||
}).addTo(mapRef.current);
|
||
addLogRef.current(
|
||
'info',
|
||
loadedFromFallback ? '全国行政区边界已通过本地静态文件加载。' : '成功加载全国行政区边界。'
|
||
);
|
||
} catch (error) {
|
||
addLogRef.current('warn', '未能加载全国行政区边界,继续使用切片底图。');
|
||
console.error('加载全国行政区边界失败:', error);
|
||
if (!TILE_LAYER_BOUNDS && mapRef.current) {
|
||
mapRef.current.setView([35.8617, 104.1954], 5);
|
||
}
|
||
}
|
||
};
|
||
void loadNationalBoundary();
|
||
|
||
L.control.zoom({ position: 'topright' }).addTo(mapRef.current);
|
||
|
||
hazardLayersGroupRef.current = L.layerGroup().addTo(mapRef.current);
|
||
|
||
addLogRef.current('info', '地图初始化完成。');
|
||
|
||
return () => {
|
||
isDisposed = true;
|
||
if (mapRef.current) {
|
||
if (mapRegionLayerRef.current) {
|
||
mapRegionLayerRef.current.remove();
|
||
mapRegionLayerRef.current = null;
|
||
}
|
||
mapRef.current.remove();
|
||
mapRef.current = null;
|
||
}
|
||
tileLayerRef.current = null;
|
||
};
|
||
}, [currentUser?.id]);
|
||
|
||
useEffect(() => {
|
||
if (!mapRef.current) return;
|
||
const config = getBaseLayerConfig(baseLayerKey);
|
||
const isInitial = !tileLayerRef.current;
|
||
|
||
if (tileLayerRef.current) {
|
||
mapRef.current.removeLayer(tileLayerRef.current);
|
||
}
|
||
|
||
try {
|
||
tileLayerRef.current = L.tileLayer(config.url, {
|
||
...TILE_LAYER_OPTIONS,
|
||
attribution: config.attribution,
|
||
}).addTo(mapRef.current);
|
||
addLogRef.current('info', `${isInitial ? '加载' : '切换'}底图:${config.label}`);
|
||
} catch (e) {
|
||
addLogRef.current('error', '加载切片底图失败,请检查配置。');
|
||
console.error('加载切片底图失败:', e);
|
||
}
|
||
}, [baseLayerKey, currentUser?.id]);
|
||
|
||
useEffect(() => {
|
||
if (licenseLoading || !authChecked) return;
|
||
const isOk = !!licenseStatus?.ok && !!currentUser;
|
||
const prevOk = prevLicenseOkRef.current;
|
||
if (isOk && !prevOk) {
|
||
initializeAppDataRef.current?.();
|
||
}
|
||
prevLicenseOkRef.current = isOk;
|
||
}, [licenseLoading, licenseStatus?.ok, authChecked, currentUser]);
|
||
|
||
const {
|
||
fetchDinsarResults,
|
||
fetchHazardPoints,
|
||
handleTaskCompletion,
|
||
handleLabelResult,
|
||
handleTrainAi,
|
||
handlePredictAll,
|
||
handleAnalyzeResult,
|
||
handleTaskStart,
|
||
initializeAppData,
|
||
} = useDinsarOperations({
|
||
onCleanupDinsarLayers: () => {
|
||
Object.values(dinsarResultLayersRef.current).forEach(layerData => {
|
||
if (layerData?.layer && mapRef.current?.hasLayer(layerData.layer)) {
|
||
layerData.layer.remove();
|
||
}
|
||
});
|
||
dinsarResultLayersRef.current = {};
|
||
},
|
||
fetchRadarImagingDates,
|
||
fetchRadarSearchOptions,
|
||
fetchAllData,
|
||
radarSearchRequestSeqRef,
|
||
});
|
||
initializeAppDataRef.current = initializeAppData;
|
||
|
||
const {
|
||
radarCurrentPage,
|
||
radarTotalPages,
|
||
dinsarCurrentPage,
|
||
dinsarTotalPages,
|
||
radarPageInputValidationError,
|
||
dinsarPageInputValidationError,
|
||
showRadarPageInputError,
|
||
showDinsarPageInputError,
|
||
handleRadarPageSizeChange,
|
||
handleDinsarPageSizeChange,
|
||
goToRadarPage,
|
||
goToDinsarPage,
|
||
changeRadarPage,
|
||
changeDinsarPage,
|
||
} = usePaginationControls({
|
||
language,
|
||
hasRadarSearched,
|
||
addLog,
|
||
radarPagination,
|
||
dinsarPagination,
|
||
radarPageInput,
|
||
dinsarPageInput,
|
||
radarPageInputTouched,
|
||
dinsarPageInputTouched,
|
||
setRadarPageInput,
|
||
setRadarPageInputTouched,
|
||
setDinsarPageInput,
|
||
setDinsarPageInputTouched,
|
||
setIsLoading,
|
||
fetchAllData,
|
||
fetchDinsarResults,
|
||
radarSearchRequestSeqRef,
|
||
});
|
||
handleTaskCompletionRef.current = handleTaskCompletion;
|
||
|
||
const {
|
||
cancelTaskPwd,
|
||
setCancelTaskPwd,
|
||
showCancelTask,
|
||
setShowCancelTask,
|
||
handleCancelActiveTasks,
|
||
} = useGlobalTaskControl({
|
||
currentUser,
|
||
licenseOk: !!licenseStatus?.ok,
|
||
activeTasks,
|
||
setActiveTasks,
|
||
pendingTaskIds,
|
||
setPendingTaskIds,
|
||
setIsCheckingTasks,
|
||
handleTaskCompletionRef,
|
||
});
|
||
|
||
const fetchRadarPreviewStatus = useCallback(async (itemId, options = {}) => {
|
||
const { silent = false } = options;
|
||
try {
|
||
const response = await apiClient.get(`/radar-data/${itemId}/preview-status`);
|
||
applyPreviewStatus(itemId, response.data);
|
||
return response.data;
|
||
} catch {
|
||
if (!silent) {
|
||
addLog('warn', `获取影像预览状态失败 (ID: ${itemId})`);
|
||
}
|
||
return null;
|
||
}
|
||
}, [addLog, applyPreviewStatus]);
|
||
|
||
const rebuildRadarPreviewCache = useCallback(async (itemId, event) => {
|
||
if (event) event.stopPropagation();
|
||
if (!ensureCanOperate()) return;
|
||
if (rebuildingPreviewIds[itemId]) return;
|
||
|
||
addLog('info', `正在生成影像 ${itemId} 的预览缓存...`);
|
||
|
||
setRebuildingPreviewIds(prev => ({ ...prev, [itemId]: true }));
|
||
try {
|
||
const response = await apiClient.post(`/radar-data/${itemId}/rebuild-preview-cache`);
|
||
const payload = response.data;
|
||
applyPreviewStatus(itemId, payload);
|
||
if (payload?.has_geo_cache) {
|
||
addLog('success', `影像 ${itemId} 纠正缓存重建完成。`);
|
||
} else if (payload?.has_raw_cache) {
|
||
addLog('warn', `影像 ${itemId} 已回退原图缓存,请检查纠正参数。`);
|
||
} else {
|
||
addLog('error', `影像 ${itemId} 预览缓存重建失败。`);
|
||
}
|
||
} catch {
|
||
addLog('error', `影像 ${itemId} 预览缓存重建失败。`);
|
||
} finally {
|
||
setRebuildingPreviewIds(prev => {
|
||
const next = { ...prev };
|
||
delete next[itemId];
|
||
return next;
|
||
});
|
||
}
|
||
}, [addLog, applyPreviewStatus, ensureCanOperate, handleTaskStart, rebuildingPreviewIds, setRebuildingPreviewIds]);
|
||
|
||
useEffect(() => {
|
||
if (!mapRef.current || !hazardLayersGroupRef.current) return;
|
||
|
||
hazardLayersGroupRef.current.clearLayers();
|
||
hazardLayersRef.current = {};
|
||
|
||
if (showHazardPoints) {
|
||
hazardPoints.forEach(point => {
|
||
const marker = L.circleMarker([point.latitude, point.longitude], {
|
||
radius: 6,
|
||
fillColor: "#e53e3e",
|
||
color: "#fff",
|
||
weight: 2,
|
||
opacity: 1,
|
||
fillOpacity: 0.8
|
||
});
|
||
|
||
const hazardName = escapeHtml(point.hazard_name);
|
||
const hazardId = escapeHtml(point.tybh);
|
||
const hazardType = escapeHtml(point.hazard_type);
|
||
const hazardCity = escapeHtml(point.city);
|
||
const hazardCounty = escapeHtml(point.county);
|
||
const hazardTownship = escapeHtml(point.township);
|
||
const hazardLongitude = formatCoordinate(point.longitude);
|
||
const hazardLatitude = formatCoordinate(point.latitude);
|
||
|
||
marker.bindPopup(`
|
||
<div class="hazard-popup">
|
||
<h4 style="margin:0 0 8px 0; color:#e53e3e;">${hazardName}</h4>
|
||
<table style="font-size:12px; border-collapse:collapse; width:100%;">
|
||
<tr><td style="color:#718096; padding:2px 0;">编号:</td><td>${hazardId}</td></tr>
|
||
<tr><td style="color:#718096; padding:2px 0;">类型:</td><td>${hazardType}</td></tr>
|
||
<tr><td style="color:#718096; padding:2px 0;">位置:</td><td>${hazardCity} ${hazardCounty} ${hazardTownship}</td></tr>
|
||
<tr><td style="color:#718096; padding:2px 0;">经纬度:</td><td>${hazardLongitude}, ${hazardLatitude}</td></tr>
|
||
</table>
|
||
</div>
|
||
`);
|
||
|
||
hazardLayersGroupRef.current.addLayer(marker);
|
||
hazardLayersRef.current[point.tybh] = marker;
|
||
});
|
||
}
|
||
}, [hazardPoints, showHazardPoints]);
|
||
|
||
const flyToHazardPoint = (point) => {
|
||
setFocusedHazardPoint(point);
|
||
// 切换到 D-InSAR 结果标签页,方便用户查看关联结果
|
||
setLeftPanelTab('dinsar_results');
|
||
addLog('info', `定位到灾害点: ${point.hazard_name} (${point.tybh}),已自动筛选覆盖该点的 D-InSAR 结果。`);
|
||
|
||
if (mapRef.current) {
|
||
mapRef.current.flyTo([point.latitude, point.longitude], 14, {
|
||
duration: 1.5
|
||
});
|
||
setTimeout(() => {
|
||
if (hazardLayersRef.current[point.tybh]) {
|
||
hazardLayersRef.current[point.tybh].openPopup();
|
||
}
|
||
}, 1600);
|
||
}
|
||
};
|
||
|
||
|
||
|
||
useEffect(() => {
|
||
if (aoiLayer) {
|
||
if (aoeLayerRef.current) {
|
||
aoeLayerRef.current.remove();
|
||
}
|
||
const aoiGeoJsonLayer = L.geoJSON(aoiLayer, {
|
||
style: {
|
||
color: '#f6e05e',
|
||
weight: 2,
|
||
opacity: 1,
|
||
dashArray: '5, 5',
|
||
fill: false,
|
||
}
|
||
}).addTo(mapRef.current);
|
||
aoeLayerRef.current = aoiGeoJsonLayer;
|
||
mapRef.current.fitBounds(aoiGeoJsonLayer.getBounds(), { padding: [50, 50] });
|
||
}
|
||
}, [aoiLayer]);
|
||
|
||
const restorePsStackPreviewVisibility = () => {
|
||
const previousVisibilityById = psStackPreviewStateRef.current?.previousVisibilityById;
|
||
if (!(previousVisibilityById instanceof Map) || previousVisibilityById.size === 0) {
|
||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||
return false;
|
||
}
|
||
|
||
let changed = false;
|
||
const currentData = allDataRef.current;
|
||
const restoredData = currentData.map(item => {
|
||
if (!previousVisibilityById.has(item.id)) {
|
||
return item;
|
||
}
|
||
const shouldBeVisible = previousVisibilityById.get(item.id);
|
||
if (item.isVisible !== shouldBeVisible) {
|
||
updateLayerVisibility(item, shouldBeVisible);
|
||
changed = true;
|
||
return { ...item, isVisible: shouldBeVisible };
|
||
}
|
||
return item;
|
||
});
|
||
|
||
if (changed) {
|
||
allDataRef.current = restoredData;
|
||
setAllData(restoredData);
|
||
}
|
||
psStackPreviewStateRef.current = { previousVisibilityById: new Map() };
|
||
return changed;
|
||
};
|
||
|
||
const clearPsStackPreview = ({ silent = false } = {}) => {
|
||
const hadLayer = !!psStackPreviewLayerRef.current;
|
||
if (psStackPreviewLayerRef.current) {
|
||
psStackPreviewLayerRef.current.remove();
|
||
psStackPreviewLayerRef.current = null;
|
||
}
|
||
const restoredVisibility = restorePsStackPreviewVisibility();
|
||
if (silent) {
|
||
return;
|
||
}
|
||
if (!hadLayer && !restoredVisibility) {
|
||
addLog('info', '当前没有打开的时序候选栈预览范围。');
|
||
return;
|
||
}
|
||
addLog('info', '已关闭时序候选栈预览范围。');
|
||
};
|
||
|
||
const previewPsStack = (stack) => {
|
||
cancelMapBatch();
|
||
clearPsStackPreview({ silent: true });
|
||
const stackIds = new Set(stack.map(img => img.id));
|
||
const currentData = allDataRef.current;
|
||
const previousVisibilityById = new Map(currentData.map(item => [item.id, item.isVisible]));
|
||
const newAllData = currentData.map(item => {
|
||
const shouldBeVisible = stackIds.has(item.id);
|
||
if (item.isVisible !== shouldBeVisible) {
|
||
updateLayerVisibility(item, shouldBeVisible);
|
||
return { ...item, isVisible: shouldBeVisible };
|
||
}
|
||
return item;
|
||
});
|
||
allDataRef.current = newAllData;
|
||
setAllData(newAllData);
|
||
psStackPreviewStateRef.current = { previousVisibilityById };
|
||
|
||
if (!mapRef.current) {
|
||
clearPsStackPreview({ silent: true });
|
||
addLog('warn', '地图尚未就绪,无法预览时序候选栈范围。');
|
||
return;
|
||
}
|
||
|
||
const previewGroup = L.layerGroup();
|
||
const allLatLngs = [];
|
||
const palette = ['#f59e0b', '#06b6d4', '#a855f7', '#22c55e', '#ef4444', '#3b82f6'];
|
||
let validSceneCount = 0;
|
||
|
||
stack.forEach((scene, index) => {
|
||
const polygon = scene.coverage_polygon;
|
||
if (!Array.isArray(polygon) || polygon.length < 3) {
|
||
return;
|
||
}
|
||
const latLngs = polygon
|
||
.filter((point) => Array.isArray(point) && point.length >= 2)
|
||
.map((point) => [Number(point[1]), Number(point[0])])
|
||
.filter(([lat, lon]) => Number.isFinite(lat) && Number.isFinite(lon));
|
||
if (latLngs.length < 3) {
|
||
return;
|
||
}
|
||
|
||
validSceneCount += 1;
|
||
allLatLngs.push(...latLngs);
|
||
const color = palette[index % palette.length];
|
||
const scenePolygon = L.polygon(latLngs, {
|
||
color,
|
||
weight: 3,
|
||
opacity: 0.95,
|
||
fillColor: color,
|
||
fillOpacity: 0.08,
|
||
dashArray: scene.stack_selection_mode === 'pairwise_sbas_network' ? '8, 5' : null,
|
||
});
|
||
scenePolygon.bindPopup(
|
||
`<strong>时序候选场景</strong><br>` +
|
||
`ID: ${escapeHtml(scene.id)}<br>` +
|
||
`日期: ${escapeHtml(scene.imaging_date || '-')}<br>` +
|
||
`卫星: ${escapeHtml(scene.satellite || '-')}<br>` +
|
||
`模式: ${escapeHtml(scene.imaging_mode || '-')} / ${escapeHtml(scene.polarization || '-')}`
|
||
);
|
||
previewGroup.addLayer(scenePolygon);
|
||
});
|
||
|
||
if (validSceneCount === 0 || allLatLngs.length < 3) {
|
||
clearPsStackPreview({ silent: true });
|
||
addLog('warn', `时序候选栈包含 ${stack.length} 个场景,但没有可绘制的覆盖范围。`);
|
||
return;
|
||
}
|
||
|
||
previewGroup.addTo(mapRef.current);
|
||
psStackPreviewLayerRef.current = previewGroup;
|
||
mapRef.current.fitBounds(L.latLngBounds(allLatLngs), { padding: [50, 50], maxZoom: 10 });
|
||
addLog('info', `正在预览包含 ${stack.length} 个场景的时序InSAR候选栈,已绘制 ${validSceneCount} 个覆盖范围。`);
|
||
};
|
||
|
||
const updateLayerTooltip = useCallback((layer, result, show) => {
|
||
if (show) {
|
||
const dates = parseDatesFromName(result.name, (value) => formatYmd(value, language));
|
||
if (dates) {
|
||
const content = `${dates.master} → ${dates.slave}`;
|
||
if (layer.getTooltip()) {
|
||
layer.setTooltipContent(content);
|
||
if (!layer.isTooltipOpen()) {
|
||
layer.openTooltip();
|
||
}
|
||
} else {
|
||
layer.bindTooltip(content, {
|
||
permanent: true,
|
||
direction: 'center',
|
||
className: 'date-tooltip',
|
||
opacity: 0.9
|
||
}).openTooltip();
|
||
}
|
||
}
|
||
} else {
|
||
if (layer.getTooltip()) {
|
||
layer.unbindTooltip();
|
||
}
|
||
}
|
||
}, [language]);
|
||
updateLayerTooltipRef.current = updateLayerTooltip;
|
||
|
||
// 当 showDates 改变时,更新所有可见图层的 Tooltip
|
||
useEffect(() => {
|
||
Object.entries(dinsarResultLayersRef.current).forEach(([id, layerData]) => {
|
||
if (layerData && layerData.layer && mapRef.current.hasLayer(layerData.layer)) {
|
||
const result = dinsarResults.find(r => r.id === parseInt(id));
|
||
if (result) {
|
||
updateLayerTooltipRef.current?.(layerData.layer, result, showDates);
|
||
}
|
||
}
|
||
});
|
||
}, [showDates, dinsarResults]);
|
||
|
||
const buildDinsarResultPopupHtml = useCallback((result) => {
|
||
const dates = parseDatesFromName(result.name, (value) => formatYmd(value, language));
|
||
const aiScore = result.ai_score === null || result.ai_score === undefined
|
||
? '-'
|
||
: `${(Number(result.ai_score) * 100).toFixed(0)}%`;
|
||
const engine = getDinsarEngineMeta(result.engine_code);
|
||
const strategy = escapeHtml(result.selection_strategy || 'legacy');
|
||
const taskAlias = escapeHtml(result.task_alias || result.task_name || result.name || '-');
|
||
const pairKey = escapeHtml(result.pair_key || '-');
|
||
const pairUid = escapeHtml(result.pair_uid || '-');
|
||
const runKey = escapeHtml(result.run_key || '-');
|
||
const networkRunId = escapeHtml(result.network_run_id || '-');
|
||
const networkEdgeId = escapeHtml(result.network_edge_id ?? '-');
|
||
const policyVersion = escapeHtml(result.policy_version || '-');
|
||
const dateText = dates
|
||
? `${escapeHtml(dates.master)} → ${escapeHtml(dates.slave)}`
|
||
: '-';
|
||
|
||
return `
|
||
<div class="dinsar-popup">
|
||
<div class="dinsar-popup-title">${escapeHtml(result.name || '-') }</div>
|
||
<div><strong>任务:</strong> ${taskAlias}</div>
|
||
<div><strong>日期:</strong> ${dateText}</div>
|
||
<div><strong>AI:</strong> ${aiScore}</div>
|
||
<div><strong>引擎:</strong> ${escapeHtml(engine.label)} <span class="mono">(${escapeHtml(engine.code)})</span></div>
|
||
<div><strong>策略:</strong> ${strategy}</div>
|
||
<div><strong>edge:</strong> ${networkEdgeId}</div>
|
||
<div><strong>run:</strong> <span class="mono">${networkRunId}</span></div>
|
||
<div><strong>run_key:</strong> <span class="mono">${runKey}</span></div>
|
||
<div><strong>pair_key:</strong> <span class="mono">${pairKey}</span></div>
|
||
<div><strong>pair_uid:</strong> <span class="mono">${pairUid}</span></div>
|
||
<div><strong>policy:</strong> ${policyVersion}</div>
|
||
</div>
|
||
`;
|
||
}, [language]);
|
||
|
||
const updateDinsarLayer = useCallback((result, shouldBeVisible) => {
|
||
const resultId = result.id;
|
||
const layerData = dinsarResultLayersRef.current[resultId];
|
||
const popupHtml = buildDinsarResultPopupHtml(result);
|
||
|
||
if (shouldBeVisible) {
|
||
let layer = layerData?.layer;
|
||
if (layer) {
|
||
// 如果图层已存在,只需将其添加回地图
|
||
if (!mapRef.current.hasLayer(layer)) { // 检查是否已在地图上
|
||
layer.addTo(mapRef.current);
|
||
}
|
||
layer.bindPopup(popupHtml, { maxWidth: 360 });
|
||
} else {
|
||
// 创建新图层
|
||
// 后端已统一接口,不再区分 thumb/full,直接请求即可
|
||
const imageUrl = `${apiClient.defaults.baseURL}/dinsar-results/${resultId}/thumb`;
|
||
addLog('info', `加载结果 ${result.name} 可视化图...`);
|
||
|
||
const bounds = [[result.min_lat, result.min_lon], [result.max_lat, result.max_lon]];
|
||
layer = L.imageOverlay(imageUrl, bounds, {
|
||
opacity: 0.7,
|
||
interactive: true,
|
||
}).addTo(mapRef.current);
|
||
|
||
layer.on('load', () => {
|
||
addLog('success', `结果 ${result.name} 的图像已加载。`);
|
||
});
|
||
layer.on('error', (e) => {
|
||
addLog('error', `无法加载结果 ${result.name} 的图像。`);
|
||
console.error(`加载图像失败 (ID: ${resultId}, URL: ${imageUrl})`, e);
|
||
layer.remove();
|
||
// 从ref中清除,以便下次可以重试
|
||
delete dinsarResultLayersRef.current[resultId];
|
||
});
|
||
layer.bindPopup(popupHtml, { maxWidth: 360 });
|
||
|
||
// 在ref中存储图层对象
|
||
dinsarResultLayersRef.current[resultId] = {
|
||
layer: layer
|
||
};
|
||
}
|
||
// 确保 Tooltip 状态正确
|
||
updateLayerTooltip(layer, result, showDates);
|
||
} else {
|
||
// 隐藏图层
|
||
if (layerData && layerData.layer && mapRef.current.hasLayer(layerData.layer)) {
|
||
layerData.layer.remove();
|
||
}
|
||
}
|
||
}, [addLog, buildDinsarResultPopupHtml, showDates, updateLayerTooltip]);
|
||
|
||
const flyToSbasProduct = useCallback((product) => {
|
||
if (!mapRef.current || !product) return false;
|
||
const bounds = getSbasProductBounds(product);
|
||
if (!bounds) {
|
||
addLog('warn', '当前 SBAS 产品没有可定位的地理范围。');
|
||
return false;
|
||
}
|
||
mapRef.current.flyToBounds(L.latLngBounds(bounds), { padding: [45, 45], maxZoom: 12 });
|
||
return true;
|
||
}, [addLog]);
|
||
|
||
const toggleSbasRateLayer = useCallback((detail, shouldBeVisible, opacity = 0.78) => {
|
||
if (!mapRef.current || !detail) return false;
|
||
const layerKey = `rate:${detail.id}`;
|
||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||
if (!shouldBeVisible) {
|
||
if (existing) existing.remove();
|
||
delete sbasAnalysisLayersRef.current[layerKey];
|
||
return true;
|
||
}
|
||
|
||
const asset = findSbasAsset(detail, ['primary_geocoded_preview', 'primary_rate_color_preview']);
|
||
const bounds = getSbasProductBounds(detail);
|
||
if (!asset || !bounds) {
|
||
addLog('warn', 'SBAS 产品缺少 LOS 速率图或地理范围,无法叠加到地图。');
|
||
return false;
|
||
}
|
||
if (existing) {
|
||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||
existing.setOpacity(opacity);
|
||
flyToSbasProduct(detail);
|
||
return true;
|
||
}
|
||
|
||
const imageUrl = getSbasInsarProductAssetUrl(detail.id, asset.id, sbasAssetCacheKey(asset));
|
||
const layer = L.imageOverlay(imageUrl, bounds, {
|
||
opacity,
|
||
interactive: true,
|
||
crossOrigin: true,
|
||
}).addTo(mapRef.current);
|
||
layer.bindPopup(
|
||
`<div class="sbas-popup"><div style="font-weight:800;margin-bottom:6px">${escapeHtml(detail.display_name || detail.run_key || 'Gamma SBAS')}</div>` +
|
||
`<div><strong>图层:</strong> LOS 速率图</div>` +
|
||
`<div><strong>色表:</strong> ${escapeHtml(detail.color_policy?.colormap || 'Gamma hls.cm')}</div>` +
|
||
`<div><strong>范围:</strong> ${escapeHtml((detail.color_policy?.display_range_mm_per_year || [-80, 80]).join(' 到 '))} mm/yr</div></div>`,
|
||
{ maxWidth: 340 },
|
||
);
|
||
layer.on('load', () => addLog('success', `SBAS LOS 速率图已加载:${detail.display_name || detail.run_key || detail.id}`));
|
||
layer.on('error', () => {
|
||
addLog('error', 'SBAS LOS 速率图加载失败。');
|
||
layer.remove();
|
||
delete sbasAnalysisLayersRef.current[layerKey];
|
||
});
|
||
sbasAnalysisLayersRef.current[layerKey] = { layer, kind: 'rate', productId: detail.id };
|
||
flyToSbasProduct(detail);
|
||
return true;
|
||
}, [addLog, flyToSbasProduct]);
|
||
|
||
const updateSbasRateOpacity = useCallback((opacity) => {
|
||
Object.values(sbasAnalysisLayersRef.current).forEach((entry) => {
|
||
if (entry?.kind === 'rate' && entry.layer?.setOpacity) {
|
||
entry.layer.setOpacity(opacity);
|
||
}
|
||
});
|
||
}, []);
|
||
|
||
const toggleSbasProductOverview = useCallback((products, shouldBeVisible) => {
|
||
if (!mapRef.current) return false;
|
||
const layerKey = 'overview';
|
||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||
if (!shouldBeVisible) {
|
||
if (existing) existing.remove();
|
||
delete sbasAnalysisLayersRef.current[layerKey];
|
||
return true;
|
||
}
|
||
if (existing) {
|
||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||
const existingBounds = sbasAnalysisLayersRef.current[layerKey]?.bounds;
|
||
if (existingBounds) {
|
||
mapRef.current.flyToBounds(existingBounds, { padding: [55, 55], maxZoom: 10 });
|
||
}
|
||
return true;
|
||
}
|
||
|
||
const validProducts = (Array.isArray(products) ? products : [])
|
||
.map((product) => ({ product, bounds: getSbasProductBounds(product) }))
|
||
.filter(item => item.bounds);
|
||
if (!validProducts.length) {
|
||
addLog('warn', '当前没有可绘制范围的 SBAS 产品。');
|
||
return false;
|
||
}
|
||
|
||
const group = L.layerGroup();
|
||
let allBounds = null;
|
||
validProducts.forEach(({ product, bounds }, index) => {
|
||
const color = SBAS_OVERVIEW_COLORS[index % SBAS_OVERVIEW_COLORS.length] || '#1d4ed8';
|
||
const rectangle = L.rectangle(bounds, {
|
||
color,
|
||
weight: 2,
|
||
opacity: 0.95,
|
||
fillColor: color,
|
||
fillOpacity: 0.08,
|
||
dashArray: index % 2 === 0 ? undefined : '6 4',
|
||
interactive: true,
|
||
});
|
||
rectangle.bindPopup(buildSbasOverviewPopupHtml(product), { maxWidth: 360 });
|
||
rectangle.bindTooltip(
|
||
`${product.display_name || product.stack_key || product.run_key || product.id}<br>${String(product.date_start || '-').slice(0, 10)} → ${String(product.date_end || '-').slice(0, 10)}`,
|
||
{ sticky: true, direction: 'top', opacity: 0.92 },
|
||
);
|
||
rectangle.addTo(group);
|
||
const nextBounds = L.latLngBounds(bounds);
|
||
allBounds = allBounds ? allBounds.extend(nextBounds) : nextBounds;
|
||
});
|
||
group.addTo(mapRef.current);
|
||
sbasAnalysisLayersRef.current[layerKey] = { layer: group, kind: 'overview', bounds: allBounds };
|
||
if (allBounds) {
|
||
mapRef.current.flyToBounds(allBounds, { padding: [55, 55], maxZoom: 10 });
|
||
}
|
||
addLog('info', `已显示 ${validProducts.length} 个 SBAS 产品范围和时间。`);
|
||
return true;
|
||
}, [addLog]);
|
||
|
||
const toggleSbasMonitorPoints = useCallback((detail, shouldBeVisible) => {
|
||
if (!mapRef.current || !detail) return false;
|
||
const layerKey = `points:${detail.id}`;
|
||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||
if (!shouldBeVisible) {
|
||
if (existing) existing.remove();
|
||
delete sbasAnalysisLayersRef.current[layerKey];
|
||
return true;
|
||
}
|
||
if (existing) {
|
||
if (!mapRef.current.hasLayer(existing)) existing.addTo(mapRef.current);
|
||
flyToSbasProduct(detail);
|
||
return true;
|
||
}
|
||
const points = (detail.monitor_points?.monitor_points || [])
|
||
.filter(point => Number.isFinite(Number(point.lat)) && Number.isFinite(Number(point.lon)));
|
||
if (!points.length) {
|
||
addLog('warn', '当前 SBAS 监测点没有 WGS84 坐标,请重新注册资产后再显示。');
|
||
return false;
|
||
}
|
||
const group = L.layerGroup();
|
||
const latLngs = [];
|
||
points.forEach((point) => {
|
||
const lat = Number(point.lat);
|
||
const lon = Number(point.lon);
|
||
const rate = Number(point.deformation_rate_mm_per_year);
|
||
const color = sbasRateColor(rate);
|
||
const marker = L.circleMarker([lat, lon], {
|
||
radius: 7,
|
||
color: '#ffffff',
|
||
weight: 2,
|
||
fillColor: color,
|
||
fillOpacity: 0.92,
|
||
interactive: true,
|
||
});
|
||
marker.bindPopup(buildSbasPointPopupHtml(point), { maxWidth: 320 });
|
||
marker.addTo(group);
|
||
latLngs.push([lat, lon]);
|
||
});
|
||
group.addTo(mapRef.current);
|
||
sbasAnalysisLayersRef.current[layerKey] = { layer: group, kind: 'points', productId: detail.id };
|
||
if (latLngs.length) {
|
||
mapRef.current.flyToBounds(L.latLngBounds(latLngs), { padding: [55, 55], maxZoom: 13 });
|
||
}
|
||
addLog('info', `已显示 ${points.length} 个 SBAS 监测点。`);
|
||
return true;
|
||
}, [addLog, flyToSbasProduct]);
|
||
|
||
const showSbasQueryPoint = useCallback((result, detail) => {
|
||
if (!mapRef.current || !result?.matched) return false;
|
||
const matched = result.matched;
|
||
const lat = Number(matched.lat);
|
||
const lon = Number(matched.lon);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return false;
|
||
const layerKey = 'query';
|
||
const existing = sbasAnalysisLayersRef.current[layerKey]?.layer;
|
||
if (existing) existing.remove();
|
||
const marker = L.circleMarker([lat, lon], {
|
||
radius: 8,
|
||
color: '#111827',
|
||
weight: 2,
|
||
fillColor: matched.used_nearest ? '#f59e0b' : '#22c55e',
|
||
fillOpacity: 0.95,
|
||
interactive: true,
|
||
}).addTo(mapRef.current);
|
||
const point = {
|
||
point_id: matched.used_nearest ? 'query_nearest' : 'query_point',
|
||
selection_label: matched.used_nearest ? '查询点最近有效像元' : '查询点',
|
||
deformation_rate_mm_per_year: matched.los_rate_mm_per_year,
|
||
displacements: result.displacements || [],
|
||
matched,
|
||
lon,
|
||
lat,
|
||
};
|
||
marker.bindPopup(buildSbasPointPopupHtml(point), { maxWidth: 320 }).openPopup();
|
||
sbasAnalysisLayersRef.current[layerKey] = { layer: marker, kind: 'query', productId: detail?.id };
|
||
mapRef.current.flyTo([lat, lon], Math.max(mapRef.current.getZoom(), 12), { animate: true });
|
||
return true;
|
||
}, []);
|
||
|
||
const sbasAnalysisPanel = {
|
||
onToggleRateLayer: toggleSbasRateLayer,
|
||
onRateOpacityChange: updateSbasRateOpacity,
|
||
onToggleMonitorPoints: toggleSbasMonitorPoints,
|
||
onToggleProductOverview: toggleSbasProductOverview,
|
||
onFlyToProduct: flyToSbasProduct,
|
||
onShowQueryPoint: showSbasQueryPoint,
|
||
onClearLayers: clearSbasAnalysisLayers,
|
||
};
|
||
|
||
const toggleDinsarResultVisibility = useCallback((resultId) => {
|
||
cancelMapBatch();
|
||
const currentResults = dinsarResultsRef.current;
|
||
const newResults = [...currentResults];
|
||
const resultIndex = newResults.findIndex(r => r.id === resultId);
|
||
if (resultIndex === -1) return;
|
||
|
||
const result = newResults[resultIndex];
|
||
const nextResult = { ...result, isVisible: !result.isVisible };
|
||
newResults[resultIndex] = nextResult;
|
||
dinsarResultsRef.current = newResults;
|
||
setDinsarResults(newResults);
|
||
updateDinsarLayer(nextResult, nextResult.isVisible);
|
||
}, [cancelMapBatch, setDinsarResults, updateDinsarLayer]);
|
||
|
||
const handleSetAllDinsarVisibility = useCallback((shouldBeVisible) => {
|
||
const currentResults = dinsarResultsRef.current;
|
||
const currentFilters = {
|
||
scoreFilter,
|
||
engineFilter,
|
||
strategyFilter,
|
||
traceSearch,
|
||
focusedHazardPoint,
|
||
};
|
||
const matchedResults = filterDinsarResults(currentResults, currentFilters);
|
||
const matchedIds = new Set(matchedResults.map((result) => result.id));
|
||
const traceKeyword = String(traceSearch || '').trim();
|
||
const filterNotes = [];
|
||
|
||
if (scoreFilter > 0) {
|
||
filterNotes.push(`AI>=${Math.round(scoreFilter * 100)}%`);
|
||
}
|
||
if (engineFilter !== DINSAR_ENGINE_ALL) {
|
||
filterNotes.push(`引擎:${getDinsarEngineMeta(engineFilter).shortLabel}`);
|
||
}
|
||
if (focusedHazardPoint?.hazard_name) {
|
||
filterNotes.push(`点位:${focusedHazardPoint.hazard_name}`);
|
||
}
|
||
if (strategyFilter !== DINSAR_STRATEGY_ALL) {
|
||
filterNotes.push(`策略:${strategyFilter}`);
|
||
}
|
||
if (traceKeyword) {
|
||
filterNotes.push(`trace:${traceKeyword}`);
|
||
}
|
||
|
||
if (matchedResults.length === 0) {
|
||
addLog('warn', `当前筛选下没有可${shouldBeVisible ? '显示' : '隐藏'}的 D-InSAR 结果。`);
|
||
return;
|
||
}
|
||
|
||
addLog(
|
||
'info',
|
||
`${shouldBeVisible ? '显示' : '隐藏'} ${matchedResults.length} 个 D-InSAR 结果${filterNotes.length > 0 ? `(${filterNotes.join(',')})` : ''}...`
|
||
);
|
||
|
||
const changedResults = [];
|
||
const nextResults = currentResults.map((result) => {
|
||
const isMatched = matchedIds.has(result.id);
|
||
const nextVisibleState = shouldBeVisible
|
||
? isMatched
|
||
: (isMatched ? false : result.isVisible);
|
||
|
||
if (result.isVisible === nextVisibleState) {
|
||
return result;
|
||
}
|
||
|
||
const nextResult = { ...result, isVisible: nextVisibleState };
|
||
changedResults.push(nextResult);
|
||
return nextResult;
|
||
});
|
||
|
||
dinsarResultsRef.current = nextResults;
|
||
setDinsarResults(nextResults);
|
||
runMapBatch(changedResults, (result) => {
|
||
updateDinsarLayer(result, result.isVisible);
|
||
});
|
||
}, [addLog, engineFilter, focusedHazardPoint, runMapBatch, scoreFilter, setDinsarResults, strategyFilter, traceSearch, updateDinsarLayer]);
|
||
|
||
const handleScoreFilterChange = useCallback((e) => {
|
||
cancelMapBatch();
|
||
const newScore = parseFloat(e.target.value);
|
||
setScoreFilter(newScore);
|
||
|
||
setDinsarResults(prevResults => {
|
||
const newResults = prevResults.map(result => {
|
||
if (result.isVisible && result.ai_score !== null && result.ai_score < newScore) {
|
||
updateDinsarLayer(result, false);
|
||
return { ...result, isVisible: false };
|
||
}
|
||
return result;
|
||
});
|
||
return newResults;
|
||
});
|
||
}, [cancelMapBatch, setDinsarResults, setScoreFilter, updateDinsarLayer]);
|
||
|
||
const handleShowDataInfo = useCallback((item, event) => {
|
||
if (event) event.stopPropagation();
|
||
setSelectedDataInfo(item);
|
||
setShowDataInfo(true);
|
||
}, [setSelectedDataInfo, setShowDataInfo]);
|
||
|
||
const {
|
||
findPairs,
|
||
createDinsarBatch,
|
||
} = usePairingLogic({
|
||
fetchRegionGeometry,
|
||
refreshBatchList,
|
||
fetchBatchItems,
|
||
onClearAoiLayer: () => {
|
||
if (aoeLayerRef.current) {
|
||
aoeLayerRef.current.remove();
|
||
aoeLayerRef.current = null;
|
||
}
|
||
},
|
||
});
|
||
|
||
const flyTo = useCallback((item) => {
|
||
if (mapRef.current && item.coverage_polygon) {
|
||
mapRef.current.closePopup();
|
||
const latLngs = item.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const bounds = L.latLngBounds(latLngs);
|
||
mapRef.current.flyToBounds(bounds, { padding: [50, 50], maxZoom: 10 });
|
||
|
||
if (item.isVisible && activeLayersRef.current[item.id]) {
|
||
mapRef.current.once('moveend', () => {
|
||
activeLayersRef.current[item.id].openPopup();
|
||
});
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const updateLayerVisibility = useCallback((item, shouldBeVisible) => {
|
||
if (shouldBeVisible) {
|
||
if (activeLayersRef.current[item.id]) return;
|
||
|
||
const latLngs = item.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const polygon = L.polygon(latLngs, {
|
||
color: item.has_orbit_data ? '#48bb78' : '#f56565',
|
||
weight: 2,
|
||
});
|
||
const originalFilename = escapeHtml(item.file_path.split(/[\\/]/).pop());
|
||
const displayName = escapeHtml(item.displayName);
|
||
const imagingDate = escapeHtml(item.imaging_date);
|
||
polygon.bindPopup(
|
||
`<strong>原始名称:</strong><br>${originalFilename}<br><br>` +
|
||
`<strong>列表名称:</strong> ${displayName}<br>` +
|
||
`<strong>日期:</strong> ${imagingDate}`
|
||
);
|
||
activeLayersRef.current[item.id] = polygon;
|
||
polygon.addTo(mapRef.current);
|
||
} else {
|
||
if (activeLayersRef.current[item.id]) {
|
||
activeLayersRef.current[item.id].remove();
|
||
delete activeLayersRef.current[item.id];
|
||
}
|
||
}
|
||
}, []);
|
||
|
||
const updateRadarPreviewVisibility = useCallback(async (item, shouldBeVisible) => {
|
||
if (!item || !mapRef.current) return;
|
||
const itemId = item.id;
|
||
const layer = radarPreviewLayersRef.current[itemId];
|
||
|
||
if (shouldBeVisible) {
|
||
const refreshedStatus = await fetchRadarPreviewStatus(itemId, { silent: true });
|
||
if (refreshedStatus) {
|
||
item = {
|
||
...item,
|
||
previewStatus: normalizePreviewStatus(refreshedStatus.status),
|
||
previewFallbackInUse: !!refreshedStatus.fallback_in_use,
|
||
previewHasGeoCache: !!refreshedStatus.has_geo_cache,
|
||
previewHasRawCache: !!refreshedStatus.has_raw_cache,
|
||
previewSourceFound: !!refreshedStatus.source_found,
|
||
previewMessage: refreshedStatus.message || '',
|
||
previewError: refreshedStatus.error || '',
|
||
previewCacheKey: refreshedStatus.cache_updated_at || item.previewCacheKey || `${Date.now()}-${itemId}`,
|
||
};
|
||
}
|
||
|
||
if (layer) {
|
||
if (!mapRef.current.hasLayer(layer)) {
|
||
layer.addTo(mapRef.current);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const cacheTag = encodeURIComponent(item.previewCacheKey || `${Date.now()}-${itemId}`);
|
||
const imageUrl = `${apiClient.defaults.baseURL}/radar-data/${itemId}/thumb?v=${cacheTag}`;
|
||
const bounds = [[item.min_lat, item.min_lon], [item.max_lat, item.max_lon]];
|
||
const previewLayer = L.imageOverlay(imageUrl, bounds, {
|
||
opacity: 0.78,
|
||
crossOrigin: true,
|
||
});
|
||
|
||
previewLayer.on('error', () => {
|
||
addLog('warn', `未找到 ${item.displayName} 的源影像缓存(或缓存生成失败)。`);
|
||
if (radarPreviewLayersRef.current[itemId]) {
|
||
radarPreviewLayersRef.current[itemId].remove();
|
||
delete radarPreviewLayersRef.current[itemId];
|
||
}
|
||
setAllData(prev => prev.map(row => (
|
||
row.id === itemId ? { ...row, isPreviewVisible: false } : row
|
||
)));
|
||
fetchRadarPreviewStatus(itemId, { silent: true });
|
||
});
|
||
|
||
radarPreviewLayersRef.current[itemId] = previewLayer;
|
||
previewLayer.addTo(mapRef.current);
|
||
} else if (layer) {
|
||
layer.remove();
|
||
delete radarPreviewLayersRef.current[itemId];
|
||
}
|
||
}, [addLog, fetchRadarPreviewStatus, setAllData]);
|
||
|
||
const toggleLayerVisibility = useCallback((itemId) => {
|
||
cancelMapBatch();
|
||
const currentData = allDataRef.current;
|
||
const itemIndex = currentData.findIndex(d => d.id === itemId);
|
||
if (itemIndex === -1) return;
|
||
|
||
const newAllData = [...currentData];
|
||
const item = { ...newAllData[itemIndex] };
|
||
item.isVisible = !item.isVisible;
|
||
newAllData[itemIndex] = item;
|
||
allDataRef.current = newAllData;
|
||
setAllData(newAllData);
|
||
|
||
updateLayerVisibility(item, item.isVisible);
|
||
}, [cancelMapBatch, setAllData, updateLayerVisibility]);
|
||
|
||
const toggleRadarPreviewVisibility = useCallback((itemId) => {
|
||
cancelMapBatch();
|
||
const currentData = allDataRef.current;
|
||
const itemIndex = currentData.findIndex(d => d.id === itemId);
|
||
if (itemIndex === -1) return;
|
||
|
||
const newAllData = [...currentData];
|
||
const item = { ...newAllData[itemIndex] };
|
||
item.isPreviewVisible = !item.isPreviewVisible;
|
||
newAllData[itemIndex] = item;
|
||
allDataRef.current = newAllData;
|
||
setAllData(newAllData);
|
||
|
||
updateRadarPreviewVisibility(item, item.isPreviewVisible);
|
||
}, [cancelMapBatch, setAllData, updateRadarPreviewVisibility]);
|
||
|
||
const handleSelectAllVisibility = useCallback((e) => {
|
||
const shouldBeVisible = e.target.checked;
|
||
const currentData = allDataRef.current;
|
||
const changedItems = [];
|
||
const newAllData = currentData.map(item => {
|
||
if (item.isVisible === shouldBeVisible) {
|
||
return item;
|
||
}
|
||
const nextItem = { ...item, isVisible: shouldBeVisible };
|
||
changedItems.push(nextItem);
|
||
return nextItem;
|
||
});
|
||
allDataRef.current = newAllData;
|
||
setAllData(newAllData);
|
||
runMapBatch(changedItems, (item) => {
|
||
updateLayerVisibility(item, item.isVisible);
|
||
});
|
||
}, [runMapBatch, setAllData, updateLayerVisibility]);
|
||
|
||
const handleSetAllRadarPreviewVisibility = useCallback((shouldBeVisible) => {
|
||
const currentData = allDataRef.current;
|
||
const changedItems = [];
|
||
const newAllData = currentData.map(item => {
|
||
if (item.isPreviewVisible === shouldBeVisible) {
|
||
return item;
|
||
}
|
||
const nextItem = { ...item, isPreviewVisible: shouldBeVisible };
|
||
changedItems.push(nextItem);
|
||
return nextItem;
|
||
});
|
||
allDataRef.current = newAllData;
|
||
setAllData(newAllData);
|
||
runMapBatch(changedItems, (item) => {
|
||
updateRadarPreviewVisibility(item, item.isPreviewVisible);
|
||
});
|
||
}, [runMapBatch, setAllData, updateRadarPreviewVisibility]);
|
||
|
||
const handleWaterSceneOnMap = (scene) => {
|
||
if (!mapRef.current || !scene.coverage_polygon) return;
|
||
const layerKey = scene.radar_data_id ? `scene_${scene.id}` : `radar_${scene.id}`;
|
||
if (waterSceneLayersRef.current[layerKey]) {
|
||
waterSceneLayersRef.current[layerKey].remove();
|
||
delete waterSceneLayersRef.current[layerKey];
|
||
return false;
|
||
}
|
||
const latLngs = scene.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const polygon = L.polygon(latLngs, {
|
||
color: '#00e5cc',
|
||
weight: 3,
|
||
fillColor: '#00e5cc',
|
||
fillOpacity: 0.12,
|
||
dashArray: '6, 4',
|
||
});
|
||
polygon.bindPopup(
|
||
`<strong>水体场景 ID=${scene.id}</strong><br>` +
|
||
`${scene.satellite || ''} · ${scene.imaging_date || ''}<br>` +
|
||
`<span style="color:#888;font-size:11px">${(scene.geo_path || '').split(/[\\/]/).pop()}</span>`
|
||
);
|
||
waterSceneLayersRef.current[layerKey] = polygon;
|
||
polygon.addTo(mapRef.current);
|
||
mapRef.current.flyToBounds(L.latLngBounds(latLngs), { padding: [50, 50], maxZoom: 10 });
|
||
polygon.openPopup();
|
||
return true;
|
||
};
|
||
|
||
const handleFloodSourcePreviewOnMap = (item) => {
|
||
if (!mapRef.current || !item) return;
|
||
const enriched = {
|
||
...item,
|
||
displayName: item.displayName || item.file_path?.split(/[\\/]/).pop() || `${item.satellite || 'SAR'} #${item.id}`,
|
||
previewCacheKey: item.previewCacheKey || item.preview_cache_updated_at || `${Date.now()}-${item.id}`,
|
||
};
|
||
if (radarPreviewLayersRef.current[enriched.id]) {
|
||
updateRadarPreviewVisibility(enriched, false);
|
||
return false;
|
||
}
|
||
updateRadarPreviewVisibility(enriched, true);
|
||
if (
|
||
enriched.min_lat != null && enriched.max_lat != null
|
||
&& enriched.min_lon != null && enriched.max_lon != null
|
||
) {
|
||
mapRef.current.flyToBounds(
|
||
L.latLngBounds([[enriched.min_lat, enriched.min_lon], [enriched.max_lat, enriched.max_lon]]),
|
||
{ padding: [50, 50], maxZoom: 10 },
|
||
);
|
||
}
|
||
return true;
|
||
};
|
||
|
||
// 洪涝事件三图层叠加(灾前/灾后/分类结果),每层可独立开关
|
||
const handleFloodEventOnMap = (ev, layers) => {
|
||
if (!mapRef.current) return;
|
||
const evId = ev.id;
|
||
|
||
// 清除该事件已有图层
|
||
const existing = floodEventLayersRef.current[evId];
|
||
if (existing) {
|
||
Object.values(existing).forEach(l => l && l.remove());
|
||
}
|
||
|
||
const created = {};
|
||
let allBounds = null;
|
||
|
||
const addImageLayer = (key, data, opacity) => {
|
||
if (!data || !data.image_b64 || !data.bounds) return;
|
||
const [minLat, minLon, maxLat, maxLon] = data.bounds;
|
||
const bounds = [[minLat, minLon], [maxLat, maxLon]];
|
||
const url = `data:image/png;base64,${data.image_b64}`;
|
||
const layer = L.imageOverlay(url, bounds, { opacity, interactive: false });
|
||
layer.addTo(mapRef.current);
|
||
created[key] = layer;
|
||
allBounds = allBounds ? allBounds.extend(bounds) : L.latLngBounds(bounds);
|
||
};
|
||
|
||
addImageLayer('pre', layers.pre, 0.75);
|
||
addImageLayer('post', layers.post, 0.75);
|
||
addImageLayer('classified', layers.classified, 0.85);
|
||
|
||
floodEventLayersRef.current[evId] = created;
|
||
|
||
if (allBounds) {
|
||
mapRef.current.flyToBounds(allBounds, { padding: [40, 40], maxZoom: 12 });
|
||
}
|
||
};
|
||
|
||
// 切换洪涝事件单个图层可见性
|
||
const toggleFloodEventLayer = (evId, key, visible) => {
|
||
const layers = floodEventLayersRef.current[evId];
|
||
if (!layers || !layers[key]) return;
|
||
if (visible) layers[key].addTo(mapRef.current);
|
||
else layers[key].remove();
|
||
};
|
||
|
||
const handleFloodPairPreviewOnMap = (pair) => {
|
||
if (!mapRef.current || !pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) return;
|
||
if (floodPairPreviewLayerRef.current) {
|
||
floodPairPreviewLayerRef.current.remove();
|
||
floodPairPreviewLayerRef.current = null;
|
||
}
|
||
const makePolygon = (scene, color, label) => {
|
||
const latLngs = scene.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const polygon = L.polygon(latLngs, {
|
||
color,
|
||
weight: 2,
|
||
fillColor: color,
|
||
fillOpacity: 0.14,
|
||
});
|
||
polygon.bindPopup(
|
||
`<strong>${escapeHtml(label)}</strong><br>` +
|
||
`场景 #${escapeHtml(scene.id)} · ${escapeHtml(scene.satellite || '')}<br>` +
|
||
`${escapeHtml(formatYmd(scene.imaging_date, language))} · ${escapeHtml(scene.polarization || '')}`
|
||
);
|
||
return polygon;
|
||
};
|
||
|
||
const prePolygon = makePolygon(pair.pre, '#2563eb', '灾前覆盖');
|
||
const postPolygon = makePolygon(pair.post, '#16a34a', '灾后覆盖');
|
||
const group = L.featureGroup([prePolygon, postPolygon]);
|
||
floodPairPreviewLayerRef.current = group;
|
||
group.addTo(mapRef.current);
|
||
mapRef.current.flyToBounds(group.getBounds(), { padding: [50, 50], maxZoom: 10 });
|
||
};
|
||
|
||
const handleFloodVectorOnMap = (impact) => {
|
||
if (!mapRef.current || !impact?.flood_vector_geojson) return;
|
||
const layerId = impact.overlay_id || impact.detection_id || 'current';
|
||
const existing = floodVectorLayersRef.current[layerId];
|
||
if (existing) existing.remove();
|
||
|
||
const layer = L.geoJSON(impact.flood_vector_geojson, {
|
||
style: {
|
||
color: '#dc2626',
|
||
weight: 2,
|
||
fillColor: '#ef4444',
|
||
fillOpacity: 0.26,
|
||
},
|
||
onEachFeature: (feature, featureLayer) => {
|
||
const props = feature?.properties || {};
|
||
featureLayer.bindPopup(
|
||
`<strong>洪涝矢量</strong><br>` +
|
||
`Overlay #${escapeHtml(layerId)}<br>` +
|
||
`${escapeHtml(props.name || props.class || '')}`
|
||
);
|
||
},
|
||
});
|
||
floodVectorLayersRef.current[layerId] = layer;
|
||
layer.addTo(mapRef.current);
|
||
const bounds = layer.getBounds();
|
||
if (bounds.isValid()) {
|
||
mapRef.current.flyToBounds(bounds, { padding: [50, 50], maxZoom: 12 });
|
||
}
|
||
};
|
||
|
||
const visualizePair = useCallback((pair) => {
|
||
const masterLatLngs = pair.master.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const slaveLatLngs = pair.slave.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const groupBounds = L.latLngBounds(masterLatLngs).extend(slaveLatLngs);
|
||
mapRef.current.flyToBounds(groupBounds, { padding: [50, 50], maxZoom: 10 });
|
||
}, []);
|
||
|
||
const togglePairVisibility = useCallback((pairIndex) => {
|
||
const currentPairs = foundPairsRef.current;
|
||
const pair = currentPairs[pairIndex];
|
||
if (!pair) return;
|
||
|
||
const nextPair = { ...pair, isVis: !pair.isVis };
|
||
const newFoundPairs = [...currentPairs];
|
||
newFoundPairs[pairIndex] = nextPair;
|
||
foundPairsRef.current = newFoundPairs;
|
||
setFoundPairs(newFoundPairs);
|
||
|
||
const pairId = nextPair.task_name;
|
||
|
||
if (nextPair.isVis) {
|
||
if (pairLayersRef.current[pairId]) {
|
||
pairLayersRef.current[pairId].addTo(mapRef.current);
|
||
} else {
|
||
const masterLatLngs = nextPair.master.coverage_polygon.map(p => [p[1], p[0]]);
|
||
const slaveLatLngs = nextPair.slave.coverage_polygon.map(p => [p[1], p[0]]);
|
||
|
||
const masterPolygon = L.polygon(masterLatLngs, { color: '#3498db', weight: 2, fillOpacity: 0.2 });
|
||
const slavePolygon = L.polygon(slaveLatLngs, { color: '#2ecc71', weight: 2, fillOpacity: 0.2 });
|
||
|
||
masterPolygon.bindPopup(`<strong>Master:</strong> ${escapeHtml(nextPair.master.id)}`);
|
||
slavePolygon.bindPopup(`<strong>Slave:</strong> ${escapeHtml(nextPair.slave.id)}`);
|
||
|
||
const group = L.featureGroup([masterPolygon, slavePolygon]);
|
||
pairLayersRef.current[pairId] = group;
|
||
group.addTo(mapRef.current);
|
||
}
|
||
} else {
|
||
if (pairLayersRef.current[pairId]) {
|
||
pairLayersRef.current[pairId].remove();
|
||
}
|
||
}
|
||
}, [setFoundPairs]);
|
||
|
||
const selectedPairsCount = foundPairs.filter(p => p.isSelected).length;
|
||
const hasEnoughRadarScenesForPlanning = radarImagingDates.length >= 2;
|
||
|
||
const licenseOk = !!licenseStatus?.ok;
|
||
const avgTaskProgress = activeTasks.length
|
||
? Math.round(activeTasks.reduce((sum, t) => sum + (t.progress || 0), 0) / activeTasks.length)
|
||
: 0;
|
||
const handleRefreshHealth = useCallback(() => {
|
||
fetchHealthStatus({ refresh: true });
|
||
}, [fetchHealthStatus]);
|
||
|
||
const openStatisticsDashboard = useCallback(() => {
|
||
setShowStats(true);
|
||
}, [setShowStats]);
|
||
|
||
const refreshDinsarResults = useCallback(() => {
|
||
fetchDinsarResults({ offset: 0 });
|
||
}, [fetchDinsarResults]);
|
||
|
||
const handleCloseCancelTask = useCallback(() => {
|
||
setShowCancelTask(false);
|
||
setCancelTaskPwd('');
|
||
}, [setShowCancelTask, setCancelTaskPwd]);
|
||
|
||
const radarPanel = {
|
||
radarCurrentPage,
|
||
radarTotalPages,
|
||
showRadarPageInputError,
|
||
radarPageInputValidationError,
|
||
onSearchAll: searchAllRadarData,
|
||
onShowStats: openStatisticsDashboard,
|
||
onSearch: applyRadarSearch,
|
||
onReset: resetRadarSearch,
|
||
onAoiModeChange: handleRadarSearchAoiModeChange,
|
||
onProvinceChange: handleRadarSearchProvinceChange,
|
||
onCityChange: handleRadarSearchCityChange,
|
||
onSetRadarSearchFiles: setRadarSearchFiles,
|
||
updateDraft: updateRadarSearchDraft,
|
||
onPageChange: changeRadarPage,
|
||
onPageSizeChange: handleRadarPageSizeChange,
|
||
onGoToPage: goToRadarPage,
|
||
onSelectAllVisibility: handleSelectAllVisibility,
|
||
onSetAllPreviewVisibility: handleSetAllRadarPreviewVisibility,
|
||
onToggleLayer: toggleLayerVisibility,
|
||
onTogglePreview: toggleRadarPreviewVisibility,
|
||
onRebuildPreview: rebuildRadarPreviewCache,
|
||
onShowDataInfo: handleShowDataInfo,
|
||
onFlyTo: flyTo,
|
||
onChangeSatelliteGroup: changeSatelliteGroup,
|
||
};
|
||
const pairingPanel = {
|
||
onOpenPairingModal: openPairingModal,
|
||
onRefreshRadarSearch: refreshCurrentRadarSearch,
|
||
onRefreshDinsar: refreshDinsarResults,
|
||
};
|
||
const taskPanel = {
|
||
onTaskStart: handleTaskStart,
|
||
};
|
||
const hazardPanel = {
|
||
onPointClick: flyToHazardPoint,
|
||
onToggleVisibility: setShowHazardPoints,
|
||
onScanComplete: fetchHazardPoints,
|
||
};
|
||
const floodPanel = {
|
||
onShowSourceSceneOnMap: handleWaterSceneOnMap,
|
||
onShowSourcePreviewOnMap: handleFloodSourcePreviewOnMap,
|
||
onShowReadyProductOnMap: handleWaterSceneOnMap,
|
||
onShowOnMap: handleWaterSceneOnMap,
|
||
onShowFloodOnMap: handleFloodEventOnMap,
|
||
onShowFloodRunOnMap: handleFloodEventOnMap,
|
||
onToggleFloodLayer: toggleFloodEventLayer,
|
||
onShowFloodPairOnMap: handleFloodPairPreviewOnMap,
|
||
onShowFloodVectorOnMap: handleFloodVectorOnMap,
|
||
};
|
||
const dinsarPanel = {
|
||
dinsarCurrentPage,
|
||
dinsarTotalPages,
|
||
showDinsarPageInputError,
|
||
dinsarPageInputValidationError,
|
||
onSetAllVisibility: handleSetAllDinsarVisibility,
|
||
onScoreFilterChange: handleScoreFilterChange,
|
||
onPageChange: changeDinsarPage,
|
||
onPageSizeChange: handleDinsarPageSizeChange,
|
||
onGoToPage: goToDinsarPage,
|
||
onToggleVisibility: toggleDinsarResultVisibility,
|
||
onLabel: handleLabelResult,
|
||
onAnalyze: handleAnalyzeResult,
|
||
};
|
||
const aiPanel = {
|
||
onTrain: handleTrainAi,
|
||
onPredictAll: handlePredictAll,
|
||
};
|
||
const pairsPanel = {
|
||
onVisualizePair: visualizePair,
|
||
onTogglePairVisibility: togglePairVisibility,
|
||
onCreateDinsarBatch: createDinsarBatch,
|
||
};
|
||
|
||
if (!authChecked) {
|
||
return (
|
||
<div className="login-page-wrapper">
|
||
<div className="login-card">
|
||
<h2>正在检查登录状态...</h2>
|
||
<p>请稍候,系统正在验证会话。</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!currentUser) {
|
||
return <LoginPage onLoginSuccess={handleLoginSuccess} />;
|
||
}
|
||
|
||
return (
|
||
<div id="app-container">
|
||
<AppStatusHeader
|
||
language={language}
|
||
setLanguage={setLanguage}
|
||
currentUser={currentUser}
|
||
isAdmin={isAdmin}
|
||
isReadOnlyUser={isReadOnlyUser}
|
||
activeTasks={activeTasks}
|
||
avgTaskProgress={avgTaskProgress}
|
||
licenseStatus={licenseStatus}
|
||
healthStatus={healthStatus}
|
||
healthLoading={healthLoading}
|
||
healthError={healthError}
|
||
onRefreshHealth={handleRefreshHealth}
|
||
onLogout={handleLogout}
|
||
/>
|
||
<div className={`main-layout${isStandaloneLeftPage ? ' main-layout--standalone' : ''}`}>
|
||
<AppSidePanel
|
||
leftPanelWidth={isStandaloneLeftPage ? '100%' : leftPanelWidth}
|
||
leftPanelTab={leftPanelTab}
|
||
setLeftPanelTab={setLeftPanelTab}
|
||
isStandalone={isStandaloneLeftPage}
|
||
isAdmin={isAdmin}
|
||
isReadOnlyUser={isReadOnlyUser}
|
||
currentUser={currentUser}
|
||
language={language}
|
||
apiEndpoint={apiClient.defaults.baseURL}
|
||
licenseOk={licenseOk}
|
||
foundPairs={foundPairs}
|
||
dinsarTotal={dinsarPagination.total}
|
||
selectedPairsCount={selectedPairsCount}
|
||
hasEnoughRadarScenesForPlanning={hasEnoughRadarScenesForPlanning}
|
||
isLoading={isLoading}
|
||
hasRadarSearched={hasRadarSearched}
|
||
showHazardPoints={showHazardPoints}
|
||
hazardPoints={hazardPoints}
|
||
aiStatus={aiStatus}
|
||
radarPanel={radarPanel}
|
||
pairingPanel={pairingPanel}
|
||
taskPanel={taskPanel}
|
||
hazardPanel={hazardPanel}
|
||
floodPanel={floodPanel}
|
||
dinsarPanel={dinsarPanel}
|
||
aiPanel={aiPanel}
|
||
pairsPanel={pairsPanel}
|
||
sbasAnalysisPanel={sbasAnalysisPanel}
|
||
/>
|
||
|
||
<div
|
||
className="panel-resizer"
|
||
onMouseDown={(event) => startResize('left', event)}
|
||
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
|
||
/>
|
||
|
||
<div
|
||
style={{
|
||
display: isStandaloneLeftPage ? 'none' : 'flex',
|
||
flex: '1 1 auto',
|
||
minWidth: 0,
|
||
minHeight: 0,
|
||
}}
|
||
>
|
||
<AppMapWorkspace
|
||
language={language}
|
||
showMapRegionLocator={showMapRegionLocator}
|
||
toggleMapRegionLocator={toggleMapRegionLocator}
|
||
mapRegionOptions={mapRegionOptions}
|
||
mapRegionSelection={mapRegionSelection}
|
||
mapRegionLoading={mapRegionLoading}
|
||
mapRegionLocating={mapRegionLocating}
|
||
mapRegionError={mapRegionError}
|
||
mapRegionLocatedName={mapRegionLocatedName}
|
||
onMapRegionProvinceChange={handleMapRegionProvinceChange}
|
||
onMapRegionCityChange={handleMapRegionCityChange}
|
||
onLocateSelectedRegion={locateSelectedRegionOnMap}
|
||
onClearMapRegionHighlight={clearMapRegionHighlight}
|
||
baseLayerKey={baseLayerKey}
|
||
setBaseLayerKey={setBaseLayerKey}
|
||
onOpenExportModal={mapExport.openExportModal}
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
className="panel-resizer"
|
||
onMouseDown={(event) => startResize('right', event)}
|
||
style={{ display: isStandaloneLeftPage ? 'none' : undefined }}
|
||
/>
|
||
|
||
<div style={{ display: isStandaloneLeftPage ? 'none' : undefined }}>
|
||
<AppLogPanel width={rightPanelWidth} />
|
||
</div>
|
||
</div>
|
||
|
||
<AppOverlays
|
||
onPairingSubmit={findPairs}
|
||
onPairingAoiModeChange={handlePairingAoiModeChange}
|
||
onPairingProvinceChange={handlePairingProvinceChange}
|
||
onPairingCityChange={handlePairingCityChange}
|
||
licenseLoading={licenseLoading}
|
||
licenseStatus={licenseStatus}
|
||
isAdmin={isAdmin}
|
||
licenseFileRef={licenseFileRef}
|
||
onUploadFile={handleLicenseUpload}
|
||
onRefreshLicenseStatus={fetchLicenseStatus}
|
||
licenseFileName={licenseFileName}
|
||
licenseUploadStatus={licenseUploadStatus}
|
||
activeTasks={activeTasks}
|
||
showCancelTask={showCancelTask}
|
||
cancelTaskPwd={cancelTaskPwd}
|
||
onShowCancelTask={() => setShowCancelTask(true)}
|
||
onCancelTaskPwdChange={setCancelTaskPwd}
|
||
onCancelTaskConfirm={handleCancelActiveTasks}
|
||
onCloseCancelTask={handleCloseCancelTask}
|
||
mapExport={mapExport}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default App;
|
||
|
||
|
||
|
||
|
||
|
||
|