import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LineChart } from 'echarts/charts';
import {
DataZoomComponent,
GridComponent,
LegendComponent,
MarkLineComponent,
TooltipComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import {
getSbasInsarProductAssetUrl,
getSbasInsarProductDetail,
listSbasInsarProducts,
querySbasInsarPointTimeseries,
} from '../api/sbasInsarProducts';
echarts.use([
LineChart,
GridComponent,
TooltipComponent,
LegendComponent,
DataZoomComponent,
MarkLineComponent,
CanvasRenderer,
]);
const panelStyle = { display: 'grid', gap: 12, padding: 16 };
const cardStyle = {
border: '1px solid #d8dee8',
borderRadius: 8,
background: '#ffffff',
overflow: 'hidden',
};
const cardBodyStyle = { display: 'grid', gap: 10, padding: 12 };
const mutedStyle = { color: '#64748b', fontSize: 12, lineHeight: 1.55 };
const labelStyle = { color: '#475569', fontSize: 12, fontWeight: 750 };
const inputStyle = {
width: '100%',
minWidth: 0,
border: '1px solid #cbd5e1',
borderRadius: 6,
padding: '7px 9px',
fontSize: 12,
boxSizing: 'border-box',
};
const buttonStyle = {
border: '1px solid #cbd5e1',
borderRadius: 6,
background: '#ffffff',
color: '#0f172a',
cursor: 'pointer',
fontSize: 12,
fontWeight: 700,
padding: '7px 11px',
};
const primaryButtonStyle = {
...buttonStyle,
borderColor: '#1d4ed8',
background: '#1d4ed8',
color: '#ffffff',
};
const chartColors = ['#1d4ed8', '#dc2626', '#059669', '#7c3aed', '#d97706', '#0f766e', '#111827'];
function formatNumber(value, digits = 2) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return '-';
return numeric.toFixed(digits);
}
function formatDate(value) {
if (!value) return '-';
return String(value).slice(0, 10);
}
function normalizeDate(value) {
const text = String(value || '').trim();
if (/^\d{8}$/.test(text)) {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
}
return text;
}
function parseDateMs(value) {
const date = normalizeDate(value);
if (!date) return NaN;
const time = Date.parse(`${date}T00:00:00Z`);
return Number.isFinite(time) ? time : NaN;
}
function normalizeDisplacements(rows) {
return (Array.isArray(rows) ? rows : [])
.map((item) => {
const date = normalizeDate(item?.date);
const time = parseDateMs(date);
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);
}
function buildLinearAxis(values, targetTicks = 6) {
const finite = values.map(Number).filter(Number.isFinite);
if (!finite.length) return { min: -1, max: 1, step: 0.5 };
let min = Math.min(...finite, 0);
let max = Math.max(...finite, 0);
if (min === max) {
const pad = Math.max(Math.abs(min) * 0.2, 1);
min -= pad;
max += pad;
}
const rawStep = (max - min) / Math.max(1, targetTicks);
const magnitude = 10 ** Math.floor(Math.log10(rawStep));
const normalized = rawStep / magnitude;
const niceFactor = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
const step = niceFactor * magnitude;
return {
min: Math.floor(min / step) * step,
max: Math.ceil(max / step) * step,
step,
};
}
function formatAxisDate(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toISOString().slice(0, 10);
}
function Metric({ label, value, accent }) {
return (
);
}
function StatusBadge({ value }) {
const color = value === 'READY' ? '#15803d' : value === 'INCOMPLETE' ? '#b45309' : value === 'ERROR' ? '#dc2626' : '#64748b';
return (
{value || 'UNKNOWN'}
);
}
function findAsset(assets, roles) {
const roleSet = new Set(roles);
return (Array.isArray(assets) ? assets : []).find((asset) => roleSet.has(asset.asset_role) && asset.exists_flag);
}
function assetCacheKey(asset) {
return [asset?.id, asset?.file_size, asset?.updated_at || asset?.created_at || asset?.relative_path]
.filter(Boolean)
.join(':');
}
function pointCardsFromDetail(detail, queryResult) {
const points = detail?.monitor_points?.monitor_points || [];
const cards = points.map((point, index) => ({
id: point.point_id || `point_${index + 1}`,
name: point.selection_label || point.point_id || `监测点 ${index + 1}`,
subName: point.selection_key || '',
rate: Number(point.deformation_rate_mm_per_year),
values: normalizeDisplacements(point.displacements),
color: chartColors[index % chartColors.length],
point,
}));
if (queryResult) {
const matched = queryResult.matched || {};
cards.push({
id: matched.used_nearest ? 'query_nearest' : 'query_exact',
name: matched.used_nearest ? '查询点最近邻' : '查询点',
subName: `${formatNumber(matched.lon, 6)}, ${formatNumber(matched.lat, 6)}`,
rate: Number(matched.los_rate_mm_per_year),
values: normalizeDisplacements(queryResult.displacements),
color: '#111827',
point: {
point_id: matched.used_nearest ? 'query_nearest' : 'query_exact',
selection_label: matched.used_nearest ? '查询点最近邻' : '查询点',
selection_key: matched.used_nearest ? `最近邻 ${formatNumber(matched.distance_m, 1)} m` : '输入点有效像元',
deformation_rate_mm_per_year: matched.los_rate_mm_per_year,
displacements: queryResult.displacements || [],
matched,
lon: matched.lon,
lat: matched.lat,
},
});
}
return cards.filter((card) => card.values.length > 0);
}
function EchartsCanvas({ option }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
if (!containerRef.current) return undefined;
const chart = echarts.init(containerRef.current, null, { renderer: 'canvas' });
chartRef.current = chart;
let resizeObserver = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => chart.resize());
resizeObserver.observe(containerRef.current);
}
const onResize = () => chart.resize();
window.addEventListener('resize', onResize);
return () => {
window.removeEventListener('resize', onResize);
resizeObserver?.disconnect();
chart.dispose();
chartRef.current = null;
};
}, []);
useEffect(() => {
if (!chartRef.current || !option) return;
chartRef.current.setOption(option, true);
}, [option]);
return ;
}
function TimeseriesChart({ cards }) {
const chart = useMemo(() => {
const validCards = (Array.isArray(cards) ? cards : []).filter((card) => card.values.length > 0);
const allValues = validCards.flatMap((card) => card.values.map((value) => value.displacement));
const allTimes = validCards.flatMap((card) => card.values.map((value) => value.time));
if (!validCards.length || !allTimes.length) return null;
return {
cards: validCards,
yAxis: buildLinearAxis(allValues, 6),
minTime: Math.min(...allTimes),
maxTime: Math.max(...allTimes),
};
}, [cards]);
const option = useMemo(() => {
if (!chart) return null;
const oneDay = 24 * 60 * 60 * 1000;
const xMin = chart.minTime === chart.maxTime ? chart.minTime - oneDay : chart.minTime;
const xMax = chart.minTime === chart.maxTime ? chart.maxTime + oneDay : chart.maxTime;
return {
animation: false,
backgroundColor: '#ffffff',
color: chart.cards.map((card) => card.color),
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'line', snap: true },
formatter: (params) => {
const rows = (Array.isArray(params) ? params : [params]).filter((item) => Array.isArray(item?.value));
if (!rows.length) return '';
const date = rows[0]?.data?.date || formatAxisDate(rows[0].value[0]);
const body = rows.map((item) => `${item.marker}${item.seriesName}: ${formatNumber(item.value[1], 2)} mm`).join('
');
return `${date}
${body}`;
},
},
legend: {
type: 'scroll',
top: 6,
left: 8,
right: 8,
itemWidth: 14,
itemHeight: 8,
textStyle: { color: '#334155', fontSize: 11 },
},
grid: { left: 72, right: 28, top: 58, bottom: 62 },
xAxis: {
type: 'time',
min: xMin,
max: xMax,
name: 'SAR Date',
nameLocation: 'middle',
nameGap: 38,
axisLabel: { color: '#64748b', hideOverlap: true, formatter: formatAxisDate },
splitLine: { show: true, lineStyle: { color: '#f1f5f9' } },
},
yAxis: {
type: 'value',
min: chart.yAxis.min,
max: chart.yAxis.max,
interval: chart.yAxis.step,
name: '累计形变 (mm)',
nameLocation: 'middle',
nameGap: 50,
axisLabel: { color: '#64748b' },
splitLine: { show: true, lineStyle: { color: '#e2e8f0' } },
},
dataZoom: [
{ type: 'inside', xAxisIndex: 0, filterMode: 'none' },
{ type: 'slider', xAxisIndex: 0, filterMode: 'none', height: 22, bottom: 18 },
],
series: chart.cards.map((card, index) => ({
name: card.name,
type: 'line',
data: card.values.map((value) => ({ value: [value.time, Number(value.displacement.toFixed(6))], date: value.date })),
showSymbol: true,
symbolSize: card.id.startsWith('query') ? 8 : 6,
smooth: false,
connectNulls: false,
lineStyle: { width: card.id.startsWith('query') ? 3 : 2.2, color: card.color },
itemStyle: { color: card.color, borderColor: '#ffffff', borderWidth: 1 },
markLine: index === 0 ? {
symbol: 'none',
silent: true,
data: [{ yAxis: 0, name: '0 mm' }],
label: { formatter: '0 mm', color: '#475569' },
lineStyle: { color: '#0f172a', opacity: 0.32, type: 'dashed', width: 1 },
} : undefined,
})),
};
}, [chart]);
if (!option) {
return (
暂无可绘制的监测点或查询点时序。
);
}
return (
监测点和查询点形变曲线
横坐标按真实 SAR 日期间隔缩放,纵坐标为累计形变 mm。
);
}
export default function SbasInsarMapAnalysisPanel({
readOnly,
onToggleRateLayer,
onRateOpacityChange,
onToggleMonitorPoints,
onToggleProductOverview,
onFlyToProduct,
onShowQueryPoint,
onClearLayers,
}) {
const [products, setProducts] = useState([]);
const [selectedId, setSelectedId] = useState('');
const [detail, setDetail] = useState(null);
const [loading, setLoading] = useState(false);
const [detailLoading, setDetailLoading] = useState(false);
const [message, setMessage] = useState('');
const [overviewVisible, setOverviewVisible] = useState(false);
const [rateVisible, setRateVisible] = useState(false);
const [monitorVisible, setMonitorVisible] = useState(false);
const [opacity, setOpacity] = useState(0.78);
const [lon, setLon] = useState('');
const [lat, setLat] = useState('');
const [queryLoading, setQueryLoading] = useState(false);
const [queryResult, setQueryResult] = useState(null);
const loadProducts = useCallback(async () => {
setLoading(true);
setMessage('');
try {
const payload = await listSbasInsarProducts({ limit: 30, offset: 0, status: 'READY' });
const nextItems = (payload?.items || []).filter((item) => (
String(item.engine_code || '').toLowerCase() === 'gamma'
|| String(item.processor_code || '').toLowerCase().includes('gamma')
));
setProducts(nextItems);
setSelectedId((prev) => (prev && nextItems.some((item) => String(item.id) === String(prev)) ? prev : String(nextItems[0]?.id || '')));
if (!nextItems.length) {
setMessage('暂无 READY 的 Gamma SBAS 产品;请先完成结果注册。');
}
} catch (error) {
setMessage(`加载 SBAS 产品失败:${error?.response?.data?.detail || error.message}`);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void loadProducts();
}, [loadProducts]);
useEffect(() => {
if (!selectedId) {
setDetail(null);
return undefined;
}
let disposed = false;
setDetailLoading(true);
setMessage('');
setOverviewVisible(false);
setRateVisible(false);
setMonitorVisible(false);
setQueryResult(null);
onClearLayers?.();
getSbasInsarProductDetail(selectedId)
.then((payload) => {
if (!disposed) setDetail(payload);
})
.catch((error) => {
if (!disposed) {
setDetail(null);
setMessage(`加载产品详情失败:${error?.response?.data?.detail || error.message}`);
}
})
.finally(() => {
if (!disposed) setDetailLoading(false);
});
return () => {
disposed = true;
};
}, [selectedId, onClearLayers]);
const selectedProduct = useMemo(
() => products.find((item) => String(item.id) === String(selectedId)) || detail,
[detail, products, selectedId],
);
const assets = detail?.assets || [];
const rateAsset = useMemo(() => findAsset(assets, ['primary_geocoded_preview', 'primary_rate_color_preview']), [assets]);
const colorbarAsset = useMemo(() => findAsset(assets, ['primary_colorbar']), [assets]);
const monitorPoints = detail?.monitor_points?.monitor_points || [];
const geocodedPointCount = monitorPoints.filter((point) => Number.isFinite(Number(point.lon)) && Number.isFinite(Number(point.lat))).length;
const chartCards = useMemo(() => pointCardsFromDetail(detail, queryResult), [detail, queryResult]);
const colorPolicy = detail?.color_policy || {};
const range = colorPolicy?.display_range_mm_per_year || [];
const rateRangeText = Array.isArray(range) && range.length >= 2
? `${formatNumber(range[0], 0)} 到 ${formatNumber(range[1], 0)} mm/yr`
: '-80 到 80 mm/yr';
const toggleRate = () => {
if (!detail) return;
const nextVisible = !rateVisible;
const ok = onToggleRateLayer?.(detail, nextVisible, opacity);
if (ok !== false) setRateVisible(nextVisible);
};
const toggleMonitor = () => {
if (!detail) return;
const nextVisible = !monitorVisible;
const ok = onToggleMonitorPoints?.(detail, nextVisible);
if (ok !== false) setMonitorVisible(nextVisible);
};
const toggleOverview = () => {
const nextVisible = !overviewVisible;
const ok = onToggleProductOverview?.(products, nextVisible);
if (ok !== false) {
setOverviewVisible(nextVisible);
setMessage(nextVisible ? `已在地图显示 ${products.length} 个 SBAS 产品范围和时间。` : '已隐藏 SBAS 产品范围总览。');
}
};
const changeOpacity = (event) => {
const next = Number(event.target.value);
setOpacity(next);
onRateOpacityChange?.(next);
};
const queryPoint = async () => {
if (!detail?.id) return;
const numericLon = Number(lon);
const numericLat = Number(lat);
if (!Number.isFinite(numericLon) || !Number.isFinite(numericLat)) {
setMessage('请输入有效 WGS84 经度和纬度。');
return;
}
if (numericLon < -180 || numericLon > 180 || numericLat < -90 || numericLat > 90) {
setMessage('经纬度超出 WGS84 范围。');
return;
}
setQueryLoading(true);
setMessage('');
try {
const result = await querySbasInsarPointTimeseries(detail.id, { lon: numericLon, lat: numericLat });
setQueryResult(result);
onShowQueryPoint?.(result, detail);
const matched = result?.matched || {};
setMessage(matched.used_nearest ? `已使用最近有效像元,距离 ${formatNumber(matched.distance_m, 1)} m。` : '查询点位于有效像元,曲线已生成。');
} catch (error) {
setQueryResult(null);
setMessage(`查询失败:${error?.response?.data?.detail || error.message}`);
} finally {
setQueryLoading(false);
}
};
return (
时序InSAR地图分析
只读检视 Gamma SBAS 成果:速率图叠加、自动监测点、WGS84 点查询和形变曲线。
{message && (
{message}
)}
{detailLoading &&
正在加载 SBAS 产品详情...
}
{detail && (
<>
} accent={detail.status === 'READY' ? '#15803d' : '#b45309'} />
地图图层
速率图使用专家链路生成的 Gamma hls.cm 浏览图;监测点使用 `disp_prt_2d` 自动选点结果。
{colorbarAsset && (
LOS 速率色卡
)}
{!rateAsset &&
未找到可叠加的 LOS 速率预览资产。
}
{monitorPoints.length > 0 && geocodedPointCount === 0 && (
当前监测点摘要没有 WGS84 经纬度;重新注册资产后可在主地图落点。
)}
WGS84 点查询
输入覆盖区内经纬度;若不是有效像元,系统会取最近有效像元并标注距离。
>
)}
{!loading && !detailLoading && !detail && (
{readOnly ? '暂无可检视的 Gamma SBAS 产品。' : '暂无可检视的 Gamma SBAS 产品。'}
)}
);
}