feat: add SBAS AOI discovery and result management
This commit is contained in:
@@ -9,6 +9,7 @@ import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
|
||||
|
||||
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
|
||||
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
|
||||
const LazySbasInsarProductsPanel = lazy(() => import('./SbasInsarProductsPanel'));
|
||||
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
|
||||
|
||||
const shellStyle = {
|
||||
@@ -67,6 +68,10 @@ export default function ProductionWorkspace({
|
||||
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
|
||||
};
|
||||
|
||||
const handleSbasProductQueued = taskId => {
|
||||
onTaskStart?.(taskId, 'SBAS-InSAR result catalog task queued.');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={shellStyle}>
|
||||
<div style={heroStyle}>
|
||||
@@ -178,6 +183,12 @@ export default function ProductionWorkspace({
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'sbas_insar_products' && (
|
||||
<LazySbasInsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
onJobQueued={handleSbasProductQueued}
|
||||
/>
|
||||
)}
|
||||
{activeView === 'dinsar_products' && (
|
||||
<LazyDinsarProductsPanel
|
||||
readOnly={readOnly}
|
||||
|
||||
@@ -94,6 +94,62 @@ function formatBytes(value) {
|
||||
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function normalizeBbox(bbox) {
|
||||
if (!bbox || typeof bbox !== 'object') return null;
|
||||
const minLon = Number(bbox.min_lon);
|
||||
const minLat = Number(bbox.min_lat);
|
||||
const maxLon = Number(bbox.max_lon);
|
||||
const maxLat = Number(bbox.max_lat);
|
||||
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
|
||||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||||
return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat };
|
||||
}
|
||||
|
||||
function formatCoord(value, digits = 5) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return numeric.toFixed(digits);
|
||||
}
|
||||
|
||||
function formatBbox(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return '-';
|
||||
return [
|
||||
formatCoord(normalized.min_lon),
|
||||
formatCoord(normalized.min_lat),
|
||||
formatCoord(normalized.max_lon),
|
||||
formatCoord(normalized.max_lat),
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function bboxCenter(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.min_lon + normalized.max_lon) / 2,
|
||||
lat: (normalized.min_lat + normalized.max_lat) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function formatCenter(center) {
|
||||
if (!center) return '-';
|
||||
const lon = Number(center.lon);
|
||||
const lat = Number(center.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '-';
|
||||
return `${formatCoord(lon)}, ${formatCoord(lat)}`;
|
||||
}
|
||||
|
||||
function formatAdminRegion(region) {
|
||||
if (!region || typeof region !== 'object') return '-';
|
||||
return region.display_name || region.name || region.tree_id || '-';
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return `${(numeric * 100).toFixed(numeric >= 0.1 ? 0 : 1)}%`;
|
||||
}
|
||||
|
||||
function StatusBadge({ value }) {
|
||||
const okValues = new Set([
|
||||
'READY',
|
||||
@@ -156,6 +212,276 @@ function RunArtifactLink({ runId, artifact }) {
|
||||
);
|
||||
}
|
||||
|
||||
function LocationSummaryPanel({ coverage }) {
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const intersection = normalizeBbox(coverage?.bbox_intersection);
|
||||
const center = coverage?.center || bboxCenter(bbox);
|
||||
const adminRegion = coverage?.admin_region;
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
return (
|
||||
<div style={{ border: '1px solid #dbeafe', borderRadius: 8, padding: 10, background: '#eff6ff' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
|
||||
<div style={valueStyle}>位置摘要</div>
|
||||
<span style={mutedStyle}>center / admin region</span>
|
||||
</div>
|
||||
<div style={{ ...metricGridStyle, marginTop: 8 }}>
|
||||
<Metric label="中心点" value={formatCenter(center)} />
|
||||
<Metric label="行政区" value={formatAdminRegion(adminRegion)} />
|
||||
<Metric label="Stack bbox" value={formatBbox(bbox)} />
|
||||
<Metric label="交集 bbox" value={formatBbox(intersection)} />
|
||||
<Metric label="单景范围数" value={`${(coverage?.scene_footprints_geojson?.features || []).length || coverage?.scene_bbox_count || 0}`} />
|
||||
<Metric label="监测点" value={`${monitorPoints.length}`} />
|
||||
</div>
|
||||
{monitorPoints.length > 0 && (
|
||||
<div style={{ ...mutedStyle, marginTop: 8, wordBreak: 'break-word' }}>
|
||||
监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCenter(point)})`).join(';')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
function UnusedSceneFootprintGeographicCoverageMap({ coverage }) {
|
||||
const mapElementRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const tileLayerRef = useRef(null);
|
||||
const layerGroupRef = useRef(null);
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
const sceneFootprints = useMemo(
|
||||
() => normalizeFeatureCollection(coverage?.scene_footprints_geojson),
|
||||
[coverage],
|
||||
);
|
||||
const coverageGeojson = useMemo(
|
||||
() => normalizeCoverageGeojson(coverage?.geojson),
|
||||
[coverage],
|
||||
);
|
||||
const sceneFeatureCount = sceneFootprints.features.length;
|
||||
const coverageFeatureCount = coverageGeojson.features.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapElementRef.current || !bbox) return undefined;
|
||||
if (!mapRef.current) {
|
||||
mapRef.current = L.map(mapElementRef.current, {
|
||||
attributionControl: false,
|
||||
zoomControl: false,
|
||||
scrollWheelZoom: false,
|
||||
doubleClickZoom: false,
|
||||
boxZoom: false,
|
||||
keyboard: false,
|
||||
dragging: true,
|
||||
});
|
||||
const baseLayer = getBaseLayerConfig(TILE_LAYER_DEFAULT_KEY);
|
||||
tileLayerRef.current = L.tileLayer(baseLayer.url, {
|
||||
...TILE_LAYER_OPTIONS,
|
||||
attribution: baseLayer.attribution,
|
||||
}).addTo(mapRef.current);
|
||||
layerGroupRef.current = L.layerGroup().addTo(mapRef.current);
|
||||
}
|
||||
|
||||
const map = mapRef.current;
|
||||
const layerGroup = layerGroupRef.current;
|
||||
layerGroup.clearLayers();
|
||||
const stackBounds = L.latLngBounds([bbox.min_lat, bbox.min_lon], [bbox.max_lat, bbox.max_lon]);
|
||||
|
||||
L.rectangle(stackBounds, {
|
||||
color: '#475569',
|
||||
weight: 1,
|
||||
dashArray: '5 5',
|
||||
fillOpacity: 0,
|
||||
}).addTo(layerGroup);
|
||||
|
||||
let fitBounds = stackBounds;
|
||||
if (sceneFeatureCount > 0) {
|
||||
const sceneLayer = L.geoJSON(sceneFootprints, {
|
||||
style: feature => {
|
||||
const date = String(feature?.properties?.date || '');
|
||||
const tone = date.endsWith('22') || date.endsWith('17') ? '#2563eb' : '#0891b2';
|
||||
return {
|
||||
color: tone,
|
||||
weight: 1.6,
|
||||
opacity: 0.9,
|
||||
fillColor: tone,
|
||||
fillOpacity: 0.12,
|
||||
};
|
||||
},
|
||||
onEachFeature: (feature, layer) => {
|
||||
const label = featureLabel(feature);
|
||||
if (label) {
|
||||
layer.bindTooltip(label, { sticky: true });
|
||||
}
|
||||
},
|
||||
}).addTo(layerGroup);
|
||||
const sceneBounds = sceneLayer.getBounds();
|
||||
if (sceneBounds.isValid()) {
|
||||
fitBounds = sceneBounds;
|
||||
}
|
||||
} else {
|
||||
L.rectangle(stackBounds, {
|
||||
color: '#2563eb',
|
||||
weight: 2,
|
||||
fillColor: '#38bdf8',
|
||||
fillOpacity: 0.12,
|
||||
}).addTo(layerGroup);
|
||||
}
|
||||
|
||||
if (coverageFeatureCount > 0) {
|
||||
L.geoJSON(coverageGeojson, {
|
||||
style: coveragePolygonStyle,
|
||||
pointToLayer: coveragePointMarker,
|
||||
onEachFeature: (feature, layer) => {
|
||||
const label = coverageFeatureLabel(feature);
|
||||
if (label) {
|
||||
layer.bindTooltip(label, { sticky: true });
|
||||
}
|
||||
},
|
||||
}).addTo(layerGroup);
|
||||
}
|
||||
|
||||
monitorPoints.forEach(point => {
|
||||
const lon = Number(point.lon);
|
||||
const lat = Number(point.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return;
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 5,
|
||||
color: '#7c3aed',
|
||||
weight: 2,
|
||||
fillColor: '#ffffff',
|
||||
fillOpacity: 1,
|
||||
})
|
||||
.bindTooltip(String(point.point_id || 'monitor point'), { direction: 'top' })
|
||||
.addTo(layerGroup);
|
||||
});
|
||||
|
||||
map.fitBounds(fitBounds.pad(0.12), { animate: false, maxZoom: 12 });
|
||||
window.setTimeout(() => map.invalidateSize(), 0);
|
||||
return undefined;
|
||||
}, [bbox, coverageFeatureCount, coverageGeojson, monitorPoints, sceneFeatureCount, sceneFootprints]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
tileLayerRef.current = null;
|
||||
layerGroupRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!bbox) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 180,
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
background: '#f8fafc',
|
||||
color: '#64748b',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
暂无可展示的地理范围
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={mapElementRef}
|
||||
style={{
|
||||
height: 180,
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
background: '#eef2f7',
|
||||
}}
|
||||
/>
|
||||
<div style={{ ...mutedStyle, display: 'flex', gap: 10, flexWrap: 'wrap', marginTop: 6 }}>
|
||||
<span><strong style={{ color: '#2563eb' }}>Blue</strong> scene footprints ({sceneFeatureCount})</span>
|
||||
<span><strong style={{ color: '#16a34a' }}>Green dashed</strong> coverage GeoJSON ({coverageFeatureCount})</span>
|
||||
<span><strong style={{ color: '#475569' }}>Gray dashed</strong> outer bbox</span>
|
||||
<span><strong style={{ color: '#7c3aed' }}>Purple</strong> monitor points</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UnusedGeographicCoveragePanel({ coverage }) {
|
||||
const bbox = normalizeBbox(coverage?.bbox);
|
||||
const intersection = normalizeBbox(coverage?.bbox_intersection);
|
||||
const center = coverage?.center || bboxCenter(bbox);
|
||||
const monitorPoints = Array.isArray(coverage?.monitor_points) ? coverage.monitor_points : [];
|
||||
const geojsonText = coverage?.geojson ? JSON.stringify(coverage.geojson) : '';
|
||||
|
||||
if (!bbox) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
<div style={valueStyle}>地理范围</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 6 }}>
|
||||
当前 Run 尚未找到 LT1 元数据 bbox。后续按行政区/AOI 生产时会在这里显示范围。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ border: '1px solid #99f6e4', borderRadius: 8, padding: 10, background: '#f0fdfa' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'center' }}>
|
||||
<div style={valueStyle}>地理范围</div>
|
||||
<span style={mutedStyle}>EPSG:4326 / GeoJSON</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(240px, 360px) minmax(0, 1fr)', gap: 10, marginTop: 8 }}>
|
||||
<SceneFootprintGeographicCoverageMap coverage={coverage} />
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={metricGridStyle}>
|
||||
<Metric label="Stack bbox" value={formatBbox(bbox)} />
|
||||
<Metric label="交集 bbox" value={formatBbox(intersection)} />
|
||||
<Metric
|
||||
label="中心点"
|
||||
value={center ? `${formatCoord(center.lon)}, ${formatCoord(center.lat)}` : '-'}
|
||||
/>
|
||||
<Metric label="单景范围数" value={`${(coverage?.scene_footprints_geojson?.features || []).length || coverage?.scene_bbox_count || 0}`} />
|
||||
<Metric label="范围来源" value={(coverage?.scene_footprints_geojson?.features || []).length > 0 ? 'scene GeoJSON' : 'stack bbox'} />
|
||||
<Metric label="监测点" value={`${monitorPoints.length}`} />
|
||||
</div>
|
||||
{monitorPoints.length > 0 && (
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-word' }}>
|
||||
监测点:{monitorPoints.map(point => `${point.point_id || 'point'} (${formatCoord(point.lon)}, ${formatCoord(point.lat)})`).join(';')}
|
||||
</div>
|
||||
)}
|
||||
{geojsonText && (
|
||||
<details>
|
||||
<summary style={{ ...mutedStyle, cursor: 'pointer', fontWeight: 650 }}>查看 GeoJSON</summary>
|
||||
<pre
|
||||
style={{
|
||||
margin: '6px 0 0',
|
||||
maxHeight: 120,
|
||||
overflow: 'auto',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
border: '1px solid #ccfbf1',
|
||||
borderRadius: 8,
|
||||
padding: 8,
|
||||
background: '#ffffff',
|
||||
color: '#334155',
|
||||
fontSize: 11,
|
||||
lineHeight: 1.45,
|
||||
}}
|
||||
>
|
||||
{geojsonText}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
*/
|
||||
|
||||
export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [capabilities, setCapabilities] = useState(null);
|
||||
const [runs, setRuns] = useState([]);
|
||||
@@ -170,6 +496,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [auditLoading, setAuditLoading] = useState(false);
|
||||
const [stackAudit, setStackAudit] = useState(null);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [stackAdminRegionQuery, setStackAdminRegionQuery] = useState('');
|
||||
const [baselineAuditLoading, setBaselineAuditLoading] = useState(false);
|
||||
const [itabDecisionLoading, setItabDecisionLoading] = useState(false);
|
||||
const [coregistrationLoading, setCoregistrationLoading] = useState(false);
|
||||
@@ -188,6 +515,20 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const [workflowJobLoading, setWorkflowJobLoading] = useState(false);
|
||||
const [workflowJob, setWorkflowJob] = useState(null);
|
||||
|
||||
const stackDiscoveryPayload = useMemo(() => {
|
||||
const adminRegion = stackAdminRegionQuery.trim();
|
||||
return {
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
include_scenes: false,
|
||||
limit: 30,
|
||||
discovery_mode: adminRegion ? 'aoi' : 'strict',
|
||||
admin_region: adminRegion || undefined,
|
||||
min_aoi_coverage_ratio: 0.01,
|
||||
min_common_overlap_ratio: 0,
|
||||
};
|
||||
}, [stackAdminRegionQuery]);
|
||||
|
||||
const loadProductionRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
@@ -240,22 +581,17 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
setError('');
|
||||
setStackAudit(null);
|
||||
try {
|
||||
const data = await discoverSbasInsarStacks({
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
include_scenes: false,
|
||||
limit: 30,
|
||||
});
|
||||
const data = await discoverSbasInsarStacks(stackDiscoveryPayload);
|
||||
const items = Array.isArray(data?.items) ? data.items : [];
|
||||
setStackCandidates(items);
|
||||
setSelectedStackId(current => current || items[0]?.stack_id || '');
|
||||
setSelectedStackId(items[0]?.stack_id || '');
|
||||
} catch (exc) {
|
||||
setError(exc?.response?.data?.detail || exc.message || 'SBAS-InSAR 栈发现失败');
|
||||
setStackCandidates([]);
|
||||
} finally {
|
||||
setDiscovering(false);
|
||||
}
|
||||
}, []);
|
||||
}, [stackDiscoveryPayload]);
|
||||
|
||||
const handleAuditStack = useCallback(async stackId => {
|
||||
if (!stackId) return;
|
||||
@@ -263,8 +599,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
setError('');
|
||||
try {
|
||||
const data = await auditSbasInsarStack(stackId, {
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
...stackDiscoveryPayload,
|
||||
include_scenes: true,
|
||||
});
|
||||
setStackAudit(data);
|
||||
} catch (exc) {
|
||||
@@ -273,7 +609,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [stackDiscoveryPayload]);
|
||||
|
||||
const handleSubmitRun = useCallback(async () => {
|
||||
if (!selectedStackId || readOnly) return;
|
||||
@@ -282,13 +618,12 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
try {
|
||||
const candidate = stackCandidates.find(item => item.stack_id === selectedStackId);
|
||||
const data = await submitSbasInsarRun(selectedStackId, {
|
||||
...stackDiscoveryPayload,
|
||||
run_label: candidate
|
||||
? `${candidate.satellite || 'LT1'} ${candidate.relative_orbit || ''} ${candidate.center_bucket || ''}`.trim()
|
||||
? `${candidate.satellite || 'LT1'} ${formatAdminRegion(candidate.admin_region)} relOrbit ${candidate.relative_orbit || ''}`.trim()
|
||||
: undefined,
|
||||
min_scenes: 3,
|
||||
require_orbits: true,
|
||||
dry_run: false,
|
||||
monitor_point_strategy: 'auto_low_sigma_high_rate',
|
||||
monitor_point_strategy: 'auto_representative_points',
|
||||
});
|
||||
const runId = data?.run?.run_id;
|
||||
const runData = await listSbasInsarRuns();
|
||||
@@ -303,7 +638,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
}
|
||||
}, [readOnly, selectedStackId, stackCandidates]);
|
||||
}, [readOnly, selectedStackId, stackCandidates, stackDiscoveryPayload]);
|
||||
|
||||
const workflowPayload = useMemo(() => ({
|
||||
force: false,
|
||||
@@ -586,6 +921,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
const iptaTimeseriesPlan = runManifest.ipta_timeseries || null;
|
||||
const publishProductsPlan = runManifest.publish_products || null;
|
||||
const monitorProductsPlan = runManifest.monitor_point_products || null;
|
||||
const runGeographicCoverage = runDetail?.geographic_coverage || null;
|
||||
const runPrimaryPreview = (
|
||||
runArtifacts.find(item => item.key === 'los_rate_toward_m_per_year_hls_geo_preview_png')
|
||||
|| runArtifacts.find(item => item.key === 'los_rate_toward_mm_per_year_geo_preview_png')
|
||||
@@ -662,12 +998,32 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>候选 SBAS 序列发现</h3>
|
||||
<h3 style={{ margin: 0, fontSize: 15, color: '#0f172a' }}>SBAS 生产区域</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
直接扫描本地 LT1 数据池,按平台、相对轨道、升降轨、模式、极化、接收站和中心桶硬分组,并检查精轨 TXT。
|
||||
按生产行政区查找覆盖同一目标区域的 LT1 时序候选,并检查日期密度、精轨和公共重叠范围。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<input
|
||||
value={stackAdminRegionQuery}
|
||||
onChange={event => {
|
||||
setStackAdminRegionQuery(event.target.value);
|
||||
setStackCandidates([]);
|
||||
setSelectedStackId('');
|
||||
setStackAudit(null);
|
||||
}}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') handleDiscoverStacks();
|
||||
}}
|
||||
placeholder="输入行政区,例如 牡丹江 / 洛阳"
|
||||
style={{
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
fontSize: 12,
|
||||
minWidth: 160,
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDiscoverStacks}
|
||||
@@ -683,7 +1039,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{discovering ? '发现中' : '发现序列'}
|
||||
{discovering ? '查找中' : '查找候选'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -742,7 +1098,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong style={{ color: '#0f172a', fontSize: 13 }}>
|
||||
{item.satellite} / relOrbit {item.relative_orbit} / {item.center_bucket}
|
||||
{item.satellite || 'LT1'} / {item.orbit_direction || '-'} / relOrbit {item.relative_orbit || '-'}
|
||||
</strong>
|
||||
<StatusBadge value={item.status} />
|
||||
</div>
|
||||
@@ -750,18 +1106,37 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
{item.date_start} 至 {item.date_end},可用 {item.usable_scene_count}/{item.scene_count} 景,
|
||||
缺精轨 {item.missing_orbit_count},最大间隔 {item.max_temporal_gap_days} 天
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
行政区:{formatAdminRegion(item.admin_region)};公共重叠 {formatPercent(item.common_overlap_ratio)}
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
覆盖 {formatPercent(item.aoi_overlap_ratio_mean)};中心点 {formatCenter(item.center)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{selectedStack && (
|
||||
<div style={metricGridStyle}>
|
||||
<Metric label="平台/模式" value={`${selectedStack.satellite || '-'} / ${selectedStack.imaging_mode || '-'}`} />
|
||||
<Metric label="轨道方向" value={selectedStack.orbit_direction || '-'} />
|
||||
<Metric label="极化/接收站" value={`${selectedStack.polarization || '-'} / ${selectedStack.receiving_station || '-'}`} />
|
||||
<Metric label="建议参考日期" value={selectedStack.reference_date || '-'} />
|
||||
</div>
|
||||
<>
|
||||
<div style={metricGridStyle}>
|
||||
<Metric label="平台/模式" value={`${selectedStack.satellite || '-'} / ${selectedStack.imaging_mode || '-'}`} />
|
||||
<Metric label="轨道方向" value={selectedStack.orbit_direction || '-'} />
|
||||
<Metric label="极化/接收站" value={`${selectedStack.polarization || '-'} / ${selectedStack.receiving_station || '-'}`} />
|
||||
<Metric label="建议参考日期" value={selectedStack.reference_date || '-'} />
|
||||
<Metric label="公共重叠" value={formatPercent(selectedStack.common_overlap_ratio)} />
|
||||
<Metric label="AOI 覆盖" value={formatPercent(selectedStack.aoi_overlap_ratio_mean)} />
|
||||
</div>
|
||||
<LocationSummaryPanel
|
||||
coverage={{
|
||||
bbox: selectedStack.bbox || selectedStack.bbox_intersection,
|
||||
bbox_intersection: selectedStack.bbox_intersection,
|
||||
center: selectedStack.center || bboxCenter(selectedStack.bbox || selectedStack.bbox_intersection),
|
||||
admin_region: selectedStack.admin_region,
|
||||
scene_bbox_count: selectedStack.usable_scene_count || selectedStack.scene_count || 0,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{stackAudit && (
|
||||
<div style={{ border: '1px solid #dbeafe', borderRadius: 8, padding: 10, background: '#eff6ff' }}>
|
||||
@@ -826,7 +1201,7 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<strong style={{ color: '#0f172a', fontSize: 13 }}>
|
||||
{item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'} / {item.center_bucket || '-'}
|
||||
{formatAdminRegion(item.admin_region)} / {item.platform || 'LT1'} / relOrbit {item.relative_orbit || '-'}
|
||||
</strong>
|
||||
<StatusBadge value={item.status} />
|
||||
</div>
|
||||
@@ -836,6 +1211,11 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{runs.length > 0 && (
|
||||
<div style={{ ...mutedStyle, padding: '2px 0 6px' }}>
|
||||
当前 Run 列表已补充中心点行政区;筛选入口优先放在候选序列发现阶段。
|
||||
</div>
|
||||
)}
|
||||
{!loading && runs.length === 0 && (
|
||||
<div style={{ ...mutedStyle, padding: '10px 0' }}>
|
||||
暂无计划 Run。先发现序列,再创建计划 Run。
|
||||
@@ -854,6 +1234,8 @@ export default function SbasInsarProductionPanel({ readOnly = false }) {
|
||||
<Metric label="下一阶段" value={run.next_stage || '-'} />
|
||||
</div>
|
||||
|
||||
<LocationSummaryPanel coverage={runGeographicCoverage} />
|
||||
|
||||
{!readOnly && (
|
||||
<div style={{ border: '1px solid #bbf7d0', borderRadius: 8, padding: 10, background: '#f0fdf4' }}>
|
||||
<div style={valueStyle}>Gamma SBAS Workflow</div>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
getSbasInsarCatalogStatus,
|
||||
getSbasInsarProductAssetUrl,
|
||||
getSbasInsarProductDetail,
|
||||
getSbasInsarProductPreviewUrl,
|
||||
listSbasInsarProducts,
|
||||
queueSbasInsarCatalogRebuild,
|
||||
} from './api/sbasInsarProducts';
|
||||
|
||||
const statusColors = {
|
||||
READY: '#15803d',
|
||||
WARN: '#b45309',
|
||||
REBUILDING: '#2563eb',
|
||||
INCOMPLETE: '#b45309',
|
||||
ERROR: '#dc2626',
|
||||
};
|
||||
|
||||
const panelStyle = { display: 'grid', gap: 12 };
|
||||
const sectionStyle = {
|
||||
background: '#ffffff',
|
||||
border: '1px solid #d8dee8',
|
||||
borderRadius: 8,
|
||||
padding: 14,
|
||||
};
|
||||
const mutedStyle = { color: '#64748b', fontSize: 12, lineHeight: 1.55 };
|
||||
const buttonStyle = {
|
||||
border: '1px solid #cbd5e1',
|
||||
borderRadius: 6,
|
||||
background: '#ffffff',
|
||||
color: '#0f172a',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 650,
|
||||
padding: '7px 11px',
|
||||
};
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return '-';
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 4) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '-';
|
||||
return numeric.toFixed(digits);
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const size = Number(value || 0);
|
||||
if (!Number.isFinite(size) || size <= 0) return '-';
|
||||
if (size < 1024) return `${size} B`;
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let current = size / 1024;
|
||||
let index = 0;
|
||||
while (current >= 1024 && index < units.length - 1) {
|
||||
current /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
return `${current.toFixed(current >= 100 ? 0 : 1)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function normalizeBbox(bbox) {
|
||||
if (!bbox || typeof bbox !== 'object') return null;
|
||||
const minLon = Number(bbox.min_lon);
|
||||
const minLat = Number(bbox.min_lat);
|
||||
const maxLon = Number(bbox.max_lon);
|
||||
const maxLat = Number(bbox.max_lat);
|
||||
if (![minLon, minLat, maxLon, maxLat].every(Number.isFinite)) return null;
|
||||
if (minLon >= maxLon || minLat >= maxLat) return null;
|
||||
return { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat };
|
||||
}
|
||||
|
||||
function bboxCenter(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return null;
|
||||
return {
|
||||
lon: (normalized.min_lon + normalized.max_lon) / 2,
|
||||
lat: (normalized.min_lat + normalized.max_lat) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function formatBbox(bbox) {
|
||||
const normalized = normalizeBbox(bbox);
|
||||
if (!normalized) return '-';
|
||||
return [
|
||||
formatNumber(normalized.min_lon, 5),
|
||||
formatNumber(normalized.min_lat, 5),
|
||||
formatNumber(normalized.max_lon, 5),
|
||||
formatNumber(normalized.max_lat, 5),
|
||||
].join(', ');
|
||||
}
|
||||
|
||||
function formatCenter(center) {
|
||||
if (!center) return '-';
|
||||
const lon = Number(center.lon);
|
||||
const lat = Number(center.lat);
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return '-';
|
||||
return `${lon.toFixed(5)}, ${lat.toFixed(5)}`;
|
||||
}
|
||||
|
||||
function formatAdminRegion(region) {
|
||||
if (!region || typeof region !== 'object') return '-';
|
||||
return region.display_name || region.name || region.tree_id || '-';
|
||||
}
|
||||
|
||||
function StatusBadge({ value }) {
|
||||
const color = statusColors[value] || '#64748b';
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '2px 9px',
|
||||
borderRadius: 999,
|
||||
background: `${color}16`,
|
||||
color,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: color }} />
|
||||
{value || 'UNKNOWN'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Metric({ label, value, accent }) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '9px 10px', background: '#f8fafc' }}>
|
||||
<div style={{ color: '#64748b', fontSize: 12 }}>{label}</div>
|
||||
<div style={{ color: accent || '#0f172a', fontSize: 15, fontWeight: 750, marginTop: 4 }}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function findFirstAsset(assets, roles) {
|
||||
const roleSet = new Set(roles);
|
||||
return assets.find(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
}
|
||||
|
||||
function findAssets(assets, roles) {
|
||||
const roleSet = new Set(roles);
|
||||
return assets.filter(asset => roleSet.has(asset.asset_role) && asset.exists_flag);
|
||||
}
|
||||
|
||||
function ProductPreview({ title, asset, productId }) {
|
||||
if (!asset) {
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: '#0f172a' }}>{title}</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 6 }}>暂无预览。</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#ffffff' }}>
|
||||
<div style={{ padding: '8px 10px', fontSize: 12, fontWeight: 700, color: '#0f172a', background: '#f8fafc' }}>
|
||||
{title}
|
||||
</div>
|
||||
<img
|
||||
src={getSbasInsarProductAssetUrl(productId, asset.id)}
|
||||
alt={title}
|
||||
style={{ display: 'block', width: '100%', maxHeight: 300, objectFit: 'contain', background: '#0f172a' }}
|
||||
/>
|
||||
<div style={{ ...mutedStyle, padding: '7px 10px', wordBreak: 'break-all' }}>{asset.relative_path}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PointVectorDownload({ asset, summary, productId }) {
|
||||
if (!asset && !summary) return null;
|
||||
const fields = Array.isArray(summary?.fields) ? summary.fields : [];
|
||||
return (
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: 12, background: '#f8fafc' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#0f172a' }}>全量有效点 GeoJSON.gz</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 4 }}>
|
||||
仅提供下载,不在前端渲染;用于 QGIS、ArcGIS、Python 或精细制图。
|
||||
</div>
|
||||
</div>
|
||||
{asset ? (
|
||||
<a href={getSbasInsarProductAssetUrl(productId, asset.id)} target="_blank" rel="noreferrer" style={{ ...buttonStyle, textDecoration: 'none' }}>
|
||||
下载
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: '#dc2626', fontSize: 12 }}>缺失</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))', gap: 8, marginTop: 10 }}>
|
||||
<Metric label="点数" value={summary?.feature_count ?? '-'} />
|
||||
<Metric label="文件大小" value={formatBytes(asset?.file_size || summary?.output_size_bytes)} />
|
||||
<Metric label="坐标系" value={summary?.crs || 'EPSG:4326'} />
|
||||
<Metric label="策略" value="download only" />
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div style={{ ...mutedStyle, marginTop: 9, wordBreak: 'break-word' }}>
|
||||
字段:{fields.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SbasInsarProductsPanel({ readOnly = false, onJobQueued }) {
|
||||
const [catalogStatus, setCatalogStatus] = useState(null);
|
||||
const [products, setProducts] = useState([]);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [adminRegionQuery, setAdminRegionQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadCatalog = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = { limit: 100, offset: 0 };
|
||||
if (query.trim()) params.query = query.trim();
|
||||
if (adminRegionQuery.trim()) params.admin_region = adminRegionQuery.trim();
|
||||
const [statusData, productData] = await Promise.all([
|
||||
getSbasInsarCatalogStatus(),
|
||||
listSbasInsarProducts(params),
|
||||
]);
|
||||
const nextProducts = Array.isArray(productData?.items) ? productData.items : [];
|
||||
setCatalogStatus(statusData);
|
||||
setProducts(nextProducts);
|
||||
setSelectedId(current => (current && nextProducts.some(item => item.id === current) ? current : nextProducts[0]?.id ?? null));
|
||||
} catch (error) {
|
||||
setCatalogStatus(null);
|
||||
setProducts([]);
|
||||
setSelectedId(null);
|
||||
setMessage(`SBAS 结果目录加载失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [adminRegionQuery, query]);
|
||||
|
||||
const loadDetail = useCallback(async productId => {
|
||||
if (!productId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setDetail(await getSbasInsarProductDetail(productId));
|
||||
} catch (error) {
|
||||
setDetail({ error: error?.response?.data?.detail || error.message });
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadCatalog();
|
||||
}, [loadCatalog]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail(selectedId);
|
||||
}, [loadDetail, selectedId]);
|
||||
|
||||
const handleRebuild = async () => {
|
||||
if (readOnly) return;
|
||||
setActionLoading(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const result = await queueSbasInsarCatalogRebuild({ full_rebuild: true });
|
||||
setMessage(`SBAS 结果目录重建任务已提交:${result.task_id}`);
|
||||
onJobQueued?.(result.task_id);
|
||||
await loadCatalog();
|
||||
} catch (error) {
|
||||
setMessage(`SBAS 结果目录重建失败:${error?.response?.data?.detail || error.message}`);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAssets = Array.isArray(detail?.assets) ? detail.assets : [];
|
||||
const selectedIssues = Array.isArray(detail?.issues) ? detail.issues : [];
|
||||
const velocityPreview = useMemo(() => findFirstAsset(selectedAssets, ['primary_geocoded_preview']), [selectedAssets]);
|
||||
const sigmaPreview = useMemo(() => findFirstAsset(selectedAssets, ['quality_geocoded_preview']), [selectedAssets]);
|
||||
const monitorPreviews = useMemo(() => findAssets(selectedAssets, ['monitor_point_curve']), [selectedAssets]);
|
||||
const pointVectorAsset = useMemo(() => findFirstAsset(selectedAssets, ['point_vector_geojson_gz']), [selectedAssets]);
|
||||
const pointVectorSummary = detail?.point_vector || {};
|
||||
const monitorPoints = detail?.monitor_points?.monitor_points || detail?.geographic_coverage?.monitor_points || [];
|
||||
const coverage = detail?.geographic_coverage || {};
|
||||
const center = detail?.center || coverage.center || bboxCenter(coverage.bbox);
|
||||
const adminRegion = detail?.admin_region || coverage.admin_region;
|
||||
const quality = detail?.quality || {};
|
||||
const rateStats = quality.los_rate_toward_mm_per_year_rdc || quality.los_rate_toward_m_per_year_rdc || {};
|
||||
const sigmaStats = quality.los_sigma_mm_per_year_rdc || quality.los_sigma_m_per_year_rdc || {};
|
||||
const catalogColor = statusColors[catalogStatus?.status] || '#64748b';
|
||||
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: '#0f172a', fontSize: 18 }}>SBAS-InSAR 结果管理</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 5 }}>
|
||||
管理 Gamma SBAS 生产结果、重要预览图、GeoTIFF、监测点曲线和发布资产。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button type="button" onClick={loadCatalog} disabled={loading || actionLoading} style={buttonStyle}>
|
||||
{loading ? '刷新中...' : '刷新'}
|
||||
</button>
|
||||
<button type="button" onClick={handleRebuild} disabled={readOnly || actionLoading} style={{ ...buttonStyle, opacity: readOnly ? 0.55 : 1 }}>
|
||||
{actionLoading ? '提交中...' : '重建目录'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 10, marginTop: 12 }}>
|
||||
<Metric label="目录状态" value={<StatusBadge value={catalogStatus?.status || 'UNKNOWN'} />} accent={catalogColor} />
|
||||
<Metric label="需要重建" value={catalogStatus?.needs_rebuild ? '是' : '否'} accent={catalogStatus?.needs_rebuild ? '#dc2626' : '#15803d'} />
|
||||
<Metric label="Run / DB" value={`${catalogStatus?.run_count ?? catalogStatus?.manifest_count ?? 0} / ${catalogStatus?.db_count ?? 0}`} />
|
||||
<Metric label="问题数" value={catalogStatus?.issue_count ?? 0} accent={(catalogStatus?.issue_count ?? 0) > 0 ? '#b45309' : '#15803d'} />
|
||||
</div>
|
||||
|
||||
<div style={{ ...mutedStyle, marginTop: 10, wordBreak: 'break-all' }}>
|
||||
<div><strong>根目录:</strong>{catalogStatus?.storage_root || '-'}</div>
|
||||
<div><strong>最近消息:</strong>{catalogStatus?.last_message || '-'}</div>
|
||||
<div><strong>最近重建:</strong>{formatDateTime(catalogStatus?.last_full_rebuild_at)}</div>
|
||||
</div>
|
||||
{message && (
|
||||
<div style={{ marginTop: 10, fontSize: 12, color: message.includes('失败') ? '#dc2626' : '#166534' }}>{message}</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: 'grid', gridTemplateColumns: 'minmax(280px, 380px) minmax(0, 1fr)', gap: 12, alignItems: 'start' }}>
|
||||
<div style={sectionStyle}>
|
||||
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
|
||||
<input
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') loadCatalog();
|
||||
}}
|
||||
placeholder="搜索 run、stack、产品编号"
|
||||
style={{ border: '1px solid #cbd5e1', borderRadius: 6, padding: '7px 9px', fontSize: 12 }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
value={adminRegionQuery}
|
||||
onChange={event => setAdminRegionQuery(event.target.value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter') loadCatalog();
|
||||
}}
|
||||
placeholder="按行政区检索,如 洛阳 / 河南"
|
||||
style={{ flex: 1, border: '1px solid #cbd5e1', borderRadius: 6, padding: '7px 9px', fontSize: 12 }}
|
||||
/>
|
||||
<button type="button" onClick={loadCatalog} style={buttonStyle}>查询</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 12, fontWeight: 750, color: '#0f172a', marginBottom: 8 }}>结果列表 ({products.length})</div>
|
||||
{products.length === 0 ? (
|
||||
<div style={{ ...mutedStyle, padding: '14px 0' }}>{loading ? '正在加载结果...' : '暂无已登记 SBAS 结果。'}</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 8, maxHeight: 720, overflowY: 'auto', paddingRight: 4 }}>
|
||||
{products.map(product => {
|
||||
const active = product.id === selectedId;
|
||||
const productCenter = product.center || bboxCenter(product);
|
||||
return (
|
||||
<button
|
||||
key={product.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(product.id)}
|
||||
style={{
|
||||
textAlign: 'left',
|
||||
border: `1px solid ${active ? '#93c5fd' : '#e2e8f0'}`,
|
||||
borderRadius: 8,
|
||||
background: active ? '#eff6ff' : '#ffffff',
|
||||
padding: '10px 11px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8, marginBottom: 4 }}>
|
||||
<strong style={{ fontSize: 12, color: '#0f172a', wordBreak: 'break-all' }}>
|
||||
{product.display_name || product.product_id}
|
||||
</strong>
|
||||
<StatusBadge value={product.status} />
|
||||
</div>
|
||||
<div style={mutedStyle}>
|
||||
{product.date_start || '-'} 至 {product.date_end || '-'} / {product.stack_size || product.scene_count || 0} 景 / {product.pair_count || 0} 对
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>行政区:{formatAdminRegion(product.admin_region)}</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 3 }}>中心点:{formatCenter(productCenter)}</div>
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-all', marginTop: 3 }}>{product.run_key || '-'}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 12 }}>
|
||||
{!selectedId ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={mutedStyle}>请选择一个 SBAS 结果。</div>
|
||||
</section>
|
||||
) : detailLoading ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={mutedStyle}>正在加载结果详情...</div>
|
||||
</section>
|
||||
) : detail?.error ? (
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ color: '#dc2626', fontSize: 13 }}>{detail.error}</div>
|
||||
</section>
|
||||
) : detail ? (
|
||||
<>
|
||||
<section style={sectionStyle}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(180px, 260px) minmax(0, 1fr)', gap: 14, alignItems: 'start' }}>
|
||||
<div style={{ border: '1px solid #e2e8f0', borderRadius: 8, overflow: 'hidden', background: '#0f172a' }}>
|
||||
<img
|
||||
src={getSbasInsarProductPreviewUrl(detail.id)}
|
||||
alt={detail.display_name}
|
||||
style={{ display: 'block', width: '100%', minHeight: 150, objectFit: 'contain' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 10, alignItems: 'flex-start' }}>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 18, color: '#0f172a' }}>{detail.display_name || detail.product_id}</h3>
|
||||
<div style={{ ...mutedStyle, marginTop: 4, wordBreak: 'break-all' }}>{detail.product_id}</div>
|
||||
</div>
|
||||
<StatusBadge value={detail.status} />
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8, marginTop: 12 }}>
|
||||
<Metric label="参考日期" value={detail.reference_date || '-'} />
|
||||
<Metric label="时间范围" value={`${detail.date_start || '-'} 至 ${detail.date_end || '-'}`} />
|
||||
<Metric label="景数 / 干涉对" value={`${detail.scene_count || detail.stack_size || 0} / ${detail.pair_count || 0}`} />
|
||||
<Metric label="监测点" value={monitorPoints.length || 0} />
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, marginTop: 10 }}>
|
||||
<div><strong>LOS 约定:</strong>{detail.los_sign_convention || 'toward radar positive; away from radar negative'}</div>
|
||||
<div><strong>Run:</strong>{detail.run_id || detail.run_key || '-'}</div>
|
||||
<div><strong>Stack:</strong>{detail.stack_key || '-'}</div>
|
||||
<div><strong>Manifest:</strong>{detail.manifest_path || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>位置摘要</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 8 }}>
|
||||
<Metric label="中心点 lon, lat" value={formatCenter(center)} />
|
||||
<Metric label="中心点行政区" value={formatAdminRegion(adminRegion)} />
|
||||
<Metric label="影像 BBox" value={formatBbox(coverage.bbox)} />
|
||||
<Metric label="交集 BBox" value={formatBbox(coverage.bbox_intersection)} />
|
||||
<Metric label="单景范围数" value={(coverage.scene_footprints_geojson?.features || []).length || coverage.scene_bbox_count || 0} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>重要产物预览</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
<ProductPreview title="LOS 速率图" asset={velocityPreview} productId={detail.id} />
|
||||
<ProductPreview title="LOS Sigma 图" asset={sigmaPreview} productId={detail.id} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<PointVectorDownload asset={pointVectorAsset} summary={pointVectorSummary} productId={detail.id} />
|
||||
</div>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 800, color: '#0f172a', marginBottom: 8 }}>监测点形变曲线</div>
|
||||
{monitorPreviews.length === 0 ? (
|
||||
<div style={{ ...mutedStyle, border: '1px solid #e2e8f0', borderRadius: 8, padding: 10, background: '#f8fafc' }}>
|
||||
暂无监测点曲线。
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: 12 }}>
|
||||
{monitorPreviews.map(asset => (
|
||||
<ProductPreview key={asset.id} title={asset.asset_name || '监测点曲线'} asset={asset} productId={detail.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>统计摘要</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 8 }}>
|
||||
<Metric label="速率中位数" value={`${formatNumber(rateStats.median, 2)} mm/yr`} />
|
||||
<Metric label="速率 P05 / P95" value={`${formatNumber(rateStats.p05, 2)} / ${formatNumber(rateStats.p95, 2)}`} />
|
||||
<Metric label="Sigma 中位数" value={`${formatNumber(sigmaStats.median, 2)} mm/yr`} />
|
||||
<Metric label="有效像元" value={rateStats.valid_count ?? '-'} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>资产下载</h4>
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
{selectedAssets.map(asset => (
|
||||
<div
|
||||
key={asset.id}
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(160px, 220px) minmax(0, 1fr) auto',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
background: asset.exists_flag ? '#ffffff' : '#fef2f2',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 750, color: '#0f172a' }}>{asset.asset_role}</div>
|
||||
<div style={mutedStyle}>{formatBytes(asset.file_size)} / {asset.format || '-'}</div>
|
||||
</div>
|
||||
<div style={{ ...mutedStyle, wordBreak: 'break-all' }}>{asset.relative_path}</div>
|
||||
{asset.exists_flag ? (
|
||||
<a href={getSbasInsarProductAssetUrl(detail.id, asset.id)} target="_blank" rel="noreferrer" style={{ color: '#1d4ed8', fontSize: 12, fontWeight: 750 }}>
|
||||
打开
|
||||
</a>
|
||||
) : (
|
||||
<span style={{ color: '#dc2626', fontSize: 12 }}>缺失</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section style={sectionStyle}>
|
||||
<h4 style={{ margin: '0 0 10px', fontSize: 15, color: '#0f172a' }}>问题</h4>
|
||||
{selectedIssues.length === 0 ? (
|
||||
<div style={{ color: '#15803d', fontSize: 12 }}>当前目录索引未发现问题。</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: 7 }}>
|
||||
{selectedIssues.map(issue => (
|
||||
<div key={issue.id} style={{ border: '1px solid #e2e8f0', borderRadius: 8, padding: '8px 10px', fontSize: 12 }}>
|
||||
<strong style={{ color: issue.severity === 'ERROR' ? '#dc2626' : '#b45309' }}>{issue.severity} / {issue.issue_code}</strong>
|
||||
<div style={{ color: '#334155', marginTop: 3 }}>{issue.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import apiClient from './client';
|
||||
|
||||
export const getSbasInsarCatalogStatus = () =>
|
||||
apiClient.get('/sbas-insar-products/catalog-status').then(r => r.data);
|
||||
|
||||
export const queueSbasInsarCatalogRebuild = payload =>
|
||||
apiClient.post('/sbas-insar-products/rebuild', payload).then(r => r.data);
|
||||
|
||||
export const listSbasInsarProducts = (params = {}) =>
|
||||
apiClient.get('/sbas-insar-products', { params }).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductDetail = productId =>
|
||||
apiClient.get(`/sbas-insar-products/${encodeURIComponent(productId)}`).then(r => r.data);
|
||||
|
||||
export const getSbasInsarProductPreviewUrl = productId =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/preview`;
|
||||
|
||||
export const getSbasInsarProductAssetUrl = (productId, assetId) =>
|
||||
`${apiClient.defaults.baseURL || '/api'}/sbas-insar-products/${encodeURIComponent(productId)}/assets/${encodeURIComponent(assetId)}`;
|
||||
@@ -70,6 +70,11 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
|
||||
label: 'SBAS-InSAR Production',
|
||||
description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves',
|
||||
},
|
||||
{
|
||||
key: 'sbas_insar_products',
|
||||
label: 'SBAS-InSAR 结果',
|
||||
description: 'Gamma SBAS LOS velocity, uncertainty, coverage and monitoring-point product catalog',
|
||||
},
|
||||
{
|
||||
key: 'dinsar_products',
|
||||
label: 'D-InSAR 产物',
|
||||
@@ -82,7 +87,7 @@ export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
|
||||
dinsar_production: 'dinsar_runs',
|
||||
dinsar_products: 'dinsar_products',
|
||||
ps_production: 'sbas_insar_production',
|
||||
ps_products: 'sbas_insar_production',
|
||||
ps_products: 'sbas_insar_products',
|
||||
});
|
||||
|
||||
export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
|
||||
|
||||
Reference in New Issue
Block a user