Files
insar-management-system-v2/frontend/src/StatisticsDashboard.jsx
T

1119 lines
38 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import {
BarChart,
HeatmapChart,
LineChart,
MapChart,
ScatterChart,
} from 'echarts/charts';
import {
GeoComponent,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
} from 'echarts/components';
import * as echarts from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { getStatisticsDashboard } from './api/stats';
echarts.use([
BarChart,
HeatmapChart,
LineChart,
MapChart,
ScatterChart,
GeoComponent,
GridComponent,
LegendComponent,
TooltipComponent,
VisualMapComponent,
CanvasRenderer,
]);
const CITY_COVERAGE_MAP_NAME = 'insar-city-coverage';
const FAMILY_COLORS = {
'LT-1': '#2563eb',
'Sentinel-1': '#0f766e',
GF3: '#d97706',
未分类: '#64748b',
};
const STATUS_COLORS = {
READY: '#16a34a',
OK: '#16a34a',
COMPLETED: '#16a34a',
SUCCESS: '#16a34a',
RUNNING: '#0ea5e9',
PENDING: '#f59e0b',
FAILED: '#dc2626',
ERROR: '#dc2626',
WARNING: '#f59e0b',
WARN: '#f59e0b',
UNKNOWN: '#64748b',
};
function formatNumber(value) {
const number = Number(value);
if (!Number.isFinite(number)) return '-';
return new Intl.NumberFormat('zh-CN').format(number);
}
function formatPercent(rate) {
const number = Number(rate);
if (!Number.isFinite(number)) return '-';
return `${(number * 100).toFixed(1)}%`;
}
function formatDuration(seconds) {
const number = Number(seconds);
if (!Number.isFinite(number) || number <= 0) return '-';
if (number < 60) return `${Math.round(number)} 秒`;
if (number < 3600) return `${Math.round(number / 60)} 分钟`;
return `${(number / 3600).toFixed(1)} 小时`;
}
function formatDateTime(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function colorForStatus(status) {
return STATUS_COLORS[String(status || '').toUpperCase()] || '#64748b';
}
function colorForFamily(family) {
return FAMILY_COLORS[family] || '#64748b';
}
function colorForCoverageCount(count, maxCount) {
const value = Number(count) || 0;
const max = Math.max(Number(maxCount) || 1, 1);
const rate = value / max;
if (rate >= 0.82) return '#08306b';
if (rate >= 0.62) return '#1261b4';
if (rate >= 0.42) return '#2f7ed8';
if (rate >= 0.24) return '#5aa9f4';
if (rate >= 0.1) return '#9bd3ff';
return '#dbeeff';
}
function isFeatureCollection(features) {
return features?.type === 'FeatureCollection'
&& Array.isArray(features.features)
&& features.features.length > 0;
}
function registerCityCoverageMap(features) {
if (!isFeatureCollection(features)) return false;
echarts.registerMap(CITY_COVERAGE_MAP_NAME, features);
return true;
}
function buildCityCoverageGrid(cityCoverage, mode = 'source') {
const payload = cityCoverage || {};
const scope = mode === 'results' ? payload.results : payload.source;
const features = Array.isArray(payload.features?.features) ? payload.features.features : [];
const regions = Array.isArray(scope?.regions) ? scope.regions : [];
if (!features.length || !regions.length) return null;
const countByTree = new Map(regions.map((item) => [String(item.tree_id), item]));
return {
source_type: 'city_regions',
total: scope.total,
covered_count: scope.matched_count,
cell_count: regions.length,
max_count: scope.max_count,
extent: {},
features: payload.features,
cells: regions.map((region) => ({
tree_id: region.tree_id,
name: region.name,
count: region.count,
lon: region.lon,
lat: region.lat,
breakdown: region.breakdown || [],
meta: countByTree.get(String(region.tree_id)),
})),
};
}
function Chart({ option, className = '', emptyText = '暂无数据' }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
const [renderError, setRenderError] = useState('');
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) return;
if (option) {
try {
chartRef.current.setOption(option, true);
setRenderError('');
} catch (err) {
console.error('Statistics chart render failed', err);
chartRef.current.clear();
setRenderError(err?.message || 'chart render failed');
}
} else {
setRenderError('');
chartRef.current.clear();
}
}, [option]);
return (
<div className={`statistics-echart ${className}`}>
<div ref={containerRef} className="statistics-echart-canvas" />
{!option && <div className="statistics-chart-empty">{emptyText}</div>}
{renderError && <div className="statistics-chart-empty statistics-chart-empty--error">图表渲染失败,请刷新或检查统计数据</div>}
</div>
);
}
function buildSourceOption(rows) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) return null;
return {
animation: false,
color: data.map((item) => colorForFamily(item.family)),
grid: { left: 36, right: 12, top: 26, bottom: 28 },
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params?.[0]?.data || {};
return `${item.family}<br/>资产:${formatNumber(item.count)} 景<br/>解析可用:${formatPercent(item.ready_rate)}`;
},
},
xAxis: {
type: 'category',
data: data.map((item) => item.family),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#475569', fontWeight: 700 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#edf2f7' } },
axisLabel: { color: '#64748b' },
},
series: [
{
type: 'bar',
barMaxWidth: 42,
data: data.map((item) => ({
...item,
value: item.count,
itemStyle: { color: colorForFamily(item.family), borderRadius: [4, 4, 0, 0] },
})),
},
],
};
}
function buildTrendOption(rows, familyNames) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) return null;
const families = familyNames?.length
? familyNames
: Array.from(new Set(data.flatMap((item) => Object.keys(item.by_family || {}))));
return {
animation: false,
color: families.map(colorForFamily),
legend: {
top: 0,
right: 0,
itemWidth: 12,
itemHeight: 8,
textStyle: { color: '#475569', fontSize: 11 },
},
grid: { left: 42, right: 12, top: 34, bottom: 28 },
tooltip: { trigger: 'axis', confine: true },
xAxis: {
type: 'category',
boundaryGap: false,
data: data.map((item) => item.month),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#edf2f7' } },
axisLabel: { color: '#64748b' },
},
series: families.map((family) => ({
name: family,
type: 'line',
smooth: true,
symbolSize: 5,
lineStyle: { width: 2 },
areaStyle: { opacity: 0.08 },
data: data.map((item) => item.by_family?.[family] || 0),
})),
};
}
function buildPipelineOption(rows) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) return null;
return {
animation: false,
grid: { left: 82, right: 38, top: 8, bottom: 10 },
tooltip: {
trigger: 'axis',
confine: true,
axisPointer: { type: 'shadow' },
formatter: (params) => {
const item = params?.[0]?.data || {};
const denominator = Number(item.denominator);
const denominatorText = Number.isFinite(denominator) && denominator > 0
? `<br/>适用总数:${formatNumber(denominator)}`
: '';
return `${item.label}<br/>数量:${formatNumber(item.value)}${denominatorText}<br/>比例:${formatPercent(item.rate)}`;
},
},
xAxis: {
type: 'value',
max: Math.max(...data.map((item) => Number(item.value) || 0), 1),
splitLine: { lineStyle: { color: '#edf2f7' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'category',
inverse: true,
data: data.map((item) => item.label),
axisTick: { show: false },
axisLine: { show: false },
axisLabel: { color: '#334155', fontWeight: 700 },
},
series: [
{
type: 'bar',
barWidth: 14,
data: data.map((item, index) => ({
...item,
itemStyle: {
color: ['#2563eb', '#0f766e', '#0891b2', '#16a34a', '#d97706'][index % 5],
borderRadius: [0, 5, 5, 0],
},
})),
label: {
show: true,
position: 'right',
color: '#475569',
formatter: (params) => {
const denominator = Number(params.data.denominator);
const totalText = Number.isFinite(denominator) && denominator > 0
? `/${formatNumber(denominator)}`
: '';
return `${formatNumber(params.data.value)}${totalText} / ${formatPercent(params.data.rate)}`;
},
},
},
],
};
}
function buildLegacyPointCoverageGrid(points, extent) {
const source = Array.isArray(points)
? points
.map((item) => ({
...item,
lon: Number(item.lon),
lat: Number(item.lat),
}))
.filter((item) => Number.isFinite(item.lon) && Number.isFinite(item.lat))
: [];
if (!source.length) return null;
const extentMinLon = Number(extent?.min_lon);
const extentMaxLon = Number(extent?.max_lon);
const extentMinLat = Number(extent?.min_lat);
const extentMaxLat = Number(extent?.max_lat);
const minLon = Number.isFinite(extentMinLon) ? extentMinLon : Math.min(...source.map((item) => item.lon));
const maxLon = Number.isFinite(extentMaxLon) ? extentMaxLon : Math.max(...source.map((item) => item.lon));
const minLat = Number.isFinite(extentMinLat) ? extentMinLat : Math.min(...source.map((item) => item.lat));
const maxLat = Number.isFinite(extentMaxLat) ? extentMaxLat : Math.max(...source.map((item) => item.lat));
const lonSpan = Math.max(maxLon - minLon, 0.01);
const latSpan = Math.max(maxLat - minLat, 0.01);
const columns = 42;
const rows = Math.max(14, Math.min(34, Math.round((columns * latSpan) / lonSpan)));
const cellLon = lonSpan / columns;
const cellLat = latSpan / rows;
const buckets = new Map();
source.forEach((item) => {
const col = Math.min(columns - 1, Math.max(0, Math.floor(((item.lon - minLon) / lonSpan) * columns)));
const row = Math.min(rows - 1, Math.max(0, Math.floor(((item.lat - minLat) / latSpan) * rows)));
const key = `${col}:${row}`;
const family = item.family || '未分类';
const bucket = buckets.get(key) || { col, row, count: 0, families: {}, examples: [] };
bucket.count += 1;
bucket.families[family] = (bucket.families[family] || 0) + 1;
if (bucket.examples.length < 4) {
bucket.examples.push({
family,
label: item.satellite || item.source_format,
date: item.date,
});
}
buckets.set(key, bucket);
});
const cells = Array.from(buckets.values()).map((bucket) => {
const dominantFamily = Object.entries(bucket.families).sort((a, b) => b[1] - a[1])[0]?.[0] || '未分类';
return {
col: bucket.col,
row: bucket.row,
count: bucket.count,
lon_min: minLon + bucket.col * cellLon,
lon_max: minLon + (bucket.col + 1) * cellLon,
lat_min: minLat + bucket.row * cellLat,
lat_max: minLat + (bucket.row + 1) * cellLat,
lon: minLon + (bucket.col + 0.5) * cellLon,
lat: minLat + (bucket.row + 0.5) * cellLat,
dominant_family: dominantFamily,
families: Object.entries(bucket.families).map(([name, count]) => ({ name, count })),
examples: bucket.examples,
};
});
return {
source_type: 'legacy_points',
total: source.length,
covered_count: source.length,
cell_count: cells.length,
max_count: Math.max(...cells.map((cell) => cell.count), 1),
columns,
rows,
extent: { min_lon: minLon, min_lat: minLat, max_lon: maxLon, max_lat: maxLat },
cells,
};
}
function buildCoverageOption(grid, mode = 'source') {
if (grid?.source_type === 'city_regions') {
if (!registerCityCoverageMap(grid.features)) return null;
const unit = mode === 'results' ? '项成果' : '景源数据';
const ownerLabel = mode === 'results' ? '成果类型' : '数据源';
const maxCount = Math.max(Number(grid?.max_count) || 0, ...grid.cells.map((item) => Number(item.count) || 0), 1);
const heatData = grid.cells
.map((region) => {
const lon = Number(region.lon);
const lat = Number(region.lat);
const count = Number(region.count) || 0;
if (!Number.isFinite(lon) || !Number.isFinite(lat) || count <= 0) return null;
return {
name: region.name,
value: [lon, lat, count],
tree_id: region.tree_id,
breakdown: region.breakdown || [],
};
})
.filter(Boolean);
return {
animation: false,
tooltip: {
trigger: 'item',
confine: true,
formatter: ({ data }) => {
const value = Array.isArray(data?.value) ? Number(data.value[2]) : Number(data?.value);
if (!data || !Number.isFinite(value) || value <= 0) {
return `${data?.name || '未命中行政区'}<br/>暂无${unit}`;
}
const breakdown = Array.isArray(data.breakdown) && data.breakdown.length
? `<br/>${ownerLabel}:${data.breakdown.map((item) => `${item.name} ${formatNumber(item.count)}`).join(',')}`
: '';
return `<b>${data.name}</b><br/>${formatNumber(value)} ${unit}${breakdown}`;
},
},
visualMap: {
show: true,
type: 'continuous',
min: 0,
max: maxCount,
orient: 'vertical',
right: 8,
bottom: 10,
calculable: false,
itemWidth: 12,
itemHeight: 78,
textStyle: { color: '#475569', fontSize: 11 },
inRange: {
color: ['#dbeafe', '#93c5fd', '#38bdf8', '#22c55e', '#facc15', '#f97316', '#dc2626'],
},
},
geo: {
map: CITY_COVERAGE_MAP_NAME,
roam: false,
silent: true,
layoutCenter: ['50%', '50%'],
layoutSize: '96%',
itemStyle: {
areaColor: '#f8fafc',
borderColor: '#cbd5e1',
borderWidth: 0.8,
},
emphasis: {
disabled: true,
},
},
series: [
{
name: mode === 'results' ? '成果热度' : '源数据热度',
type: 'heatmap',
coordinateSystem: 'geo',
pointSize: mode === 'results' ? 34 : 28,
blurSize: mode === 'results' ? 42 : 36,
minOpacity: 0.18,
maxOpacity: 0.92,
data: heatData,
},
{
name: mode === 'results' ? '成果命中市' : '源数据命中市',
type: 'scatter',
coordinateSystem: 'geo',
symbolSize: (value) => {
const count = Number(value?.[2]) || 0;
return Math.max(5, Math.min(15, 5 + (count / maxCount) * 10));
},
itemStyle: {
color: 'rgba(15, 23, 42, 0.72)',
borderColor: '#ffffff',
borderWidth: 1,
},
label: {
show: true,
position: 'right',
color: '#0f172a',
fontSize: 11,
fontWeight: 800,
formatter: ({ data }) => (Number(data?.value?.[2]) >= maxCount * 0.28 ? data.name : ''),
},
emphasis: {
label: { show: true },
itemStyle: { color: '#0f172a' },
},
data: heatData,
},
],
};
}
const cells = Array.isArray(grid?.cells) ? grid.cells : [];
const extent = grid?.extent || {};
if (!cells.length) return null;
const minLon = Number(extent.min_lon);
const maxLon = Number(extent.max_lon);
const minLat = Number(extent.min_lat);
const maxLat = Number(extent.max_lat);
if (![minLon, maxLon, minLat, maxLat].every(Number.isFinite)) return null;
const lonSpan = Math.max(maxLon - minLon, 0.01);
const latSpan = Math.max(maxLat - minLat, 0.01);
const lonPadding = lonSpan * 0.04;
const latPadding = latSpan * 0.04;
const maxCount = Math.max(Number(grid?.max_count) || 0, ...cells.map((item) => Number(item.count) || 0), 1);
const unit = mode === 'results' ? '项成果' : '景源数据';
const ownerLabel = mode === 'results' ? '主成果类型' : '主数据源';
const data = cells.map((cell) => ({
value: [
Number(cell.lon_min),
Number(cell.lat_min),
Number(cell.lon_max),
Number(cell.lat_max),
Number(cell.count) || 0,
],
meta: cell,
}));
return {
animation: false,
grid: { left: 8, right: 8, top: 8, bottom: 8 },
tooltip: {
trigger: 'item',
confine: true,
formatter: ({ data: item }) => {
const meta = item?.meta;
if (!meta) return '';
const owner = mode === 'results' ? meta.dominant_catalog : meta.dominant_family;
return [
`<b>${formatNumber(meta.count)} ${unit}</b>`,
owner ? `${ownerLabel}:${owner}` : '',
`经度:${Number(meta.lon_min).toFixed(3)} - ${Number(meta.lon_max).toFixed(3)}`,
`纬度:${Number(meta.lat_min).toFixed(3)} - ${Number(meta.lat_max).toFixed(3)}`,
meta.examples.length
? `样例:${meta.examples.map((example) => `${example.family || example.catalog || ''} ${example.date || ''}`.trim()).filter(Boolean).join(',')}`
: '',
].filter(Boolean).join('<br/>');
},
},
xAxis: {
type: 'value',
min: minLon - lonPadding,
max: maxLon + lonPadding,
show: false,
},
yAxis: {
type: 'value',
min: minLat - latPadding,
max: maxLat + latPadding,
show: false,
scale: true,
},
series: [
{
name: '覆盖密度',
type: 'custom',
renderItem: (params, api) => {
const lonMin = api.value(0);
const latMin = api.value(1);
const lonMax = api.value(2);
const latMax = api.value(3);
const count = api.value(4);
const topLeft = api.coord([lonMin, latMax]);
const bottomRight = api.coord([lonMax, latMin]);
const rect = echarts.graphic.clipRectByRect(
{
x: topLeft[0],
y: topLeft[1],
width: Math.max(bottomRight[0] - topLeft[0], 1.5),
height: Math.max(bottomRight[1] - topLeft[1], 1.5),
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height,
},
);
if (!rect) return null;
return {
type: 'rect',
shape: rect,
style: {
fill: colorForCoverageCount(count, maxCount),
stroke: 'rgba(255,255,255,0.72)',
lineWidth: 0.6,
},
emphasis: {
style: {
stroke: '#0f172a',
lineWidth: 1.2,
},
},
};
},
data,
},
],
};
}
function buildStatusOption(rows, key = 'status') {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) return null;
return {
animation: false,
grid: { left: 80, right: 26, top: 8, bottom: 12 },
tooltip: { trigger: 'axis', confine: true, axisPointer: { type: 'shadow' } },
xAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#edf2f7' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'category',
inverse: true,
data: data.map((item) => item[key]),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: '#334155', fontWeight: 700 },
},
series: [
{
type: 'bar',
barWidth: 14,
data: data.map((item) => ({
value: item.count,
itemStyle: { color: colorForStatus(item[key]), borderRadius: [0, 5, 5, 0] },
})),
label: { show: true, position: 'right', color: '#475569' },
},
],
};
}
function buildResultTrendOption(rows) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) return null;
const catalogs = Array.from(new Set(data.flatMap((item) => Object.keys(item.by_catalog || {}))));
const colors = ['#2563eb', '#16a34a', '#d97706', '#7c3aed'];
return {
animation: false,
color: colors,
legend: {
top: 0,
right: 0,
itemWidth: 12,
itemHeight: 8,
textStyle: { color: '#475569', fontSize: 11 },
},
grid: { left: 42, right: 12, top: 34, bottom: 28 },
tooltip: { trigger: 'axis', confine: true },
xAxis: {
type: 'category',
data: data.map((item) => item.month),
axisTick: { show: false },
axisLine: { lineStyle: { color: '#cbd5e1' } },
axisLabel: { color: '#64748b' },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#edf2f7' } },
axisLabel: { color: '#64748b' },
},
series: catalogs.map((catalog) => ({
name: catalog === 'sbas_insar' ? 'SBAS' : catalog.toUpperCase(),
type: 'bar',
stack: 'result',
barMaxWidth: 28,
data: data.map((item) => item.by_catalog?.[catalog] || 0),
})),
};
}
function KpiCard({ item }) {
return (
<article className={`statistics-kpi-card statistics-kpi-card--${item.tone || 'primary'}`}>
<div className="statistics-kpi-label">{item.label}</div>
<div className="statistics-kpi-main">
<strong>{formatNumber(item.value)}</strong>
<span>{item.unit}</span>
</div>
<div className="statistics-kpi-note">{item.note}</div>
</article>
);
}
function Section({ title, subtitle, className = '', children }) {
return (
<section className={`statistics-section ${className}`}>
<div className="statistics-section-header">
<h2>{title}</h2>
{subtitle && <span>{subtitle}</span>}
</div>
{children}
</section>
);
}
function FamilyLegend({ rows }) {
const data = Array.isArray(rows) ? rows : [];
return (
<div className="statistics-family-list">
{data.map((item) => (
<div key={item.family} className="statistics-family-item">
<span className="statistics-family-swatch" style={{ background: colorForFamily(item.family) }} />
<span>{item.family}</span>
<strong>{formatNumber(item.count)}</strong>
<em>{formatPercent(item.ready_rate)} 可用</em>
</div>
))}
</div>
);
}
function formatYmd(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 MissingOrbitSummary({ summary }) {
const affectedSceneCount = Number(summary?.affected_scene_count || 0);
const missingDateCount = Number(summary?.missing_orbit_date_count || 0);
const bySatellite = Array.isArray(summary?.by_satellite) ? summary.by_satellite : [];
if (!affectedSceneCount) {
return (
<div className="statistics-orbit-gap statistics-orbit-gap--ok">
<strong>精轨缺口</strong>
<span>当前没有开放的缺精轨场景。</span>
</div>
);
}
return (
<div className="statistics-orbit-gap">
<div className="statistics-orbit-gap-head">
<div>
<strong>精轨缺口说明</strong>
<span>缺轨按受影响影像景数统计;补齐时按卫星日期准备 LT-1 TXT 精轨。</span>
</div>
<div className="statistics-orbit-gap-metrics">
<div>
<span>受影响景数</span>
<b>{formatNumber(affectedSceneCount)}</b>
</div>
<div>
<span>缺失卫星日期</span>
<b>{formatNumber(missingDateCount)}</b>
</div>
</div>
</div>
<div className="statistics-orbit-gap-list">
{bySatellite.map((item) => {
const months = Array.isArray(item.months) ? item.months : [];
const dates = Array.isArray(item.dates) ? item.dates : [];
return (
<div className="statistics-orbit-gap-satellite" key={item.satellite}>
<div className="statistics-orbit-gap-satellite-head">
<strong>{item.satellite || 'UNKNOWN'}</strong>
<span>
{formatNumber(item.affected_scene_count)} 景 / {formatNumber(item.missing_orbit_date_count)} 个日期
{item.first_missing_date && item.last_missing_date
? ` / ${formatYmd(item.first_missing_date)} 至 ${formatYmd(item.last_missing_date)}`
: ''}
</span>
</div>
<div className="statistics-orbit-month-grid">
{months.map((month) => (
<span key={`${item.satellite}-${month.month}`}>
{month.month}
<b>{formatNumber(month.affected_scene_count)}景</b>
<em>{formatNumber(month.missing_orbit_date_count)}日</em>
</span>
))}
</div>
<div className="statistics-orbit-date-row" aria-label={`${item.satellite} 缺失精轨日期`}>
{dates.map((date) => (
<span key={`${item.satellite}-${date.date}`} title={`${formatNumber(date.affected_scene_count)} 景受影响`}>
{formatYmd(date.date)}
<b>{formatNumber(date.affected_scene_count)}</b>
</span>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
function ProductionRunList({ rows }) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) {
return <div className="statistics-empty-line">暂无生产运行记录</div>;
}
return (
<div className="statistics-run-list">
{data.map((item) => (
<div key={item.run_id} className="statistics-run-row">
<div>
<strong>{item.run_id}</strong>
<span>{item.engine_code} · {formatDateTime(item.created_at)}</span>
</div>
<b style={{ color: colorForStatus(item.status) }}>{item.status}</b>
<em>{formatNumber(item.completed_items)}/{formatNumber(item.total_items)}</em>
</div>
))}
</div>
);
}
function InventoryStateList({ rows }) {
const data = Array.isArray(rows) ? rows : [];
if (!data.length) {
return <div className="statistics-empty-line">暂无扫描状态记录</div>;
}
return (
<div className="statistics-inventory-list">
{data.slice(0, 6).map((item) => (
<div key={`${item.inventory_type}-${item.last_scan_finished_at || item.status}`} className="statistics-inventory-row">
<div>
<strong>{item.inventory_type || '未分类扫描'}</strong>
<span>{formatDateTime(item.last_scan_finished_at || item.last_scan_started_at)}</span>
</div>
<b style={{ color: colorForStatus(item.status) }}>{item.status}</b>
<em>{formatNumber(item.last_asset_count)} 项</em>
</div>
))}
</div>
);
}
export default function StatisticsDashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [lastLoadedAt, setLastLoadedAt] = useState(null);
const [coverageMode, setCoverageMode] = useState('source');
const loadDashboard = async ({ fresh = false } = {}) => {
setLoading(true);
setError('');
try {
let payload;
try {
payload = await getStatisticsDashboard(fresh);
} catch (err) {
if (!fresh) throw err;
payload = await getStatisticsDashboard(false);
}
setData(payload);
setLastLoadedAt(new Date());
} catch (err) {
setError(err.response?.data?.detail || err.message || '统计数据获取失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadDashboard();
}, []);
const families = useMemo(
() => (data?.asset?.source_by_family || []).map((item) => item.family),
[data],
);
const sourceOption = useMemo(
() => buildSourceOption(data?.asset?.source_by_family),
[data],
);
const sourceTrendOption = useMemo(
() => buildTrendOption(data?.asset?.source_by_month, families),
[data, families],
);
const pipelineOption = useMemo(
() => buildPipelineOption(data?.asset?.pipeline),
[data],
);
const coverageOption = useMemo(
() => buildCoverageOption(
buildCityCoverageGrid(data?.coverage?.city_regions, coverageMode)
|| (coverageMode === 'results' ? data?.coverage?.results : data?.coverage?.source),
coverageMode,
),
[data, coverageMode],
);
const productionStatusOption = useMemo(
() => buildStatusOption(data?.production?.run_status),
[data],
);
const taskStatusOption = useMemo(
() => buildStatusOption(data?.production?.dinsar_task_status),
[data],
);
const resultTrendOption = useMemo(
() => buildResultTrendOption(data?.results?.results_by_month),
[data],
);
const issueCodeOption = useMemo(
() => buildStatusOption(data?.issues?.by_code, 'code'),
[data],
);
const activeCoverage = buildCityCoverageGrid(data?.coverage?.city_regions, coverageMode)
|| (coverageMode === 'results' ? data?.coverage?.results : data?.coverage?.source);
const coverageSubtitle = coverageMode === 'results'
? `按市级行政区统计,命中 ${formatNumber(activeCoverage?.cell_count)} 个市,已定位 ${formatNumber(activeCoverage?.covered_count)} / ${formatNumber(activeCoverage?.total)} 项`
: `按市级行政区统计,命中 ${formatNumber(activeCoverage?.cell_count)} 个市,已定位 ${formatNumber(activeCoverage?.covered_count)} / ${formatNumber(activeCoverage?.total || data?.coverage?.point_total)} 景`;
return (
<div className="statistics-workspace">
<div className="statistics-content">
<header className="statistics-hero">
<div>
<h1>InSAR 数据与生产统计</h1>
<p>
汇总源数据接入、元数据解析、精轨保障、生产运行和成果健康状态,用于判断系统工程能力和待处理风险。
</p>
</div>
<div className="statistics-hero-actions">
<span>{data?.generated_at ? `统计时间 ${formatDateTime(data.generated_at)}` : '等待统计数据'}</span>
{data?.cache_meta?.enabled && (
<span>
{data.cache_meta.hit ? '缓存命中' : '已重算'}
{data.cache_meta.ttl_seconds ? ` · ${data.cache_meta.ttl_seconds}秒缓存` : ''}
</span>
)}
<button type="button" onClick={() => loadDashboard({ fresh: true })} disabled={loading}>
{loading ? '刷新中' : '刷新'}
</button>
</div>
</header>
{error && (
<div className="statistics-state statistics-state--error">
{error}
</div>
)}
{loading && !data ? (
<div className="statistics-skeleton-grid">
{Array.from({ length: 8 }).map((_, index) => (
<div key={index} className="statistics-skeleton" />
))}
</div>
) : (
<>
<section className="statistics-kpi-grid" aria-label="核心统计指标">
{(data?.kpis || []).map((item) => (
<KpiCard key={item.key} item={item} />
))}
</section>
<section className="statistics-command-grid">
<div className="statistics-map-panel">
<div className="statistics-map-header">
<div>
<h2>{coverageMode === 'results' ? '成果市级覆盖热力' : '源数据市级覆盖热力'}</h2>
<span>
{coverageSubtitle}
</span>
</div>
<div className="statistics-map-tools">
<div className="statistics-segmented-control" aria-label="覆盖统计对象">
<button
type="button"
className={coverageMode === 'source' ? 'is-active' : ''}
onClick={() => setCoverageMode('source')}
>
源数据
</button>
<button
type="button"
className={coverageMode === 'results' ? 'is-active' : ''}
onClick={() => setCoverageMode('results')}
>
成果
</button>
</div>
{coverageMode === 'source' && <FamilyLegend rows={data?.asset?.source_by_family} />}
</div>
</div>
<Chart option={coverageOption} className="statistics-map-chart" emptyText="暂无可用空间覆盖数据" />
</div>
<div className="statistics-side-stack">
<Section title="数据底座" subtitle="按数据源统计">
<Chart option={sourceOption} className="statistics-chart-sm" />
</Section>
<Section title="生产链路" subtitle="入库到可生产">
<Chart option={pipelineOption} className="statistics-chart-sm" />
</Section>
</div>
</section>
<section className="statistics-dashboard-grid">
<Section title="源数据时间分布" subtitle="按影像月份">
<Chart option={sourceTrendOption} className="statistics-chart-md" />
</Section>
<Section
title="精轨保障"
subtitle={`${formatNumber(data?.orbit?.selected_bindings)} 景已选中,${formatNumber(data?.orbit?.missing_summary?.affected_scene_count || data?.orbit?.missing_scene_count)} 景待补`}
className="statistics-section--orbit"
>
<div className="statistics-orbit-layout">
<div className="statistics-orbit-overview">
<div className="statistics-orbit-summary">
<div>
<span>精轨资产</span>
<strong>{formatNumber(data?.orbit?.orbit_total)}</strong>
</div>
<div>
<span>绑定覆盖率</span>
<strong>{formatPercent(data?.orbit?.selected_rate)}</strong>
</div>
<div>
<span>匹配记录</span>
<strong>{formatNumber(data?.orbit?.matched_bindings)}</strong>
</div>
</div>
<div className="statistics-mini-bars">
{(data?.orbit?.orbit_by_family || []).map((item) => (
<div key={item.family}>
<span>{item.family}</span>
<b>{formatNumber(item.count)}</b>
<i style={{ width: `${Math.min(100, Math.max(2, item.ok_rate * 100))}%` }} />
</div>
))}
</div>
</div>
<MissingOrbitSummary summary={data?.orbit?.missing_summary} />
</div>
</Section>
<Section title="D-InSAR 生产运行" subtitle={`平均耗时 ${formatDuration(data?.production?.avg_duration_seconds)}`}>
<Chart option={productionStatusOption} className="statistics-chart-md" />
</Section>
<Section title="任务规划状态" subtitle={`${formatNumber(data?.production?.dinsar_task_count)} 个任务项`}>
<Chart option={taskStatusOption} className="statistics-chart-md" />
</Section>
<Section title="成果产出趋势" subtitle={`${formatNumber(data?.results?.result_total)} 项成果`}>
<Chart option={resultTrendOption} className="statistics-chart-md" />
</Section>
<Section title="问题闭环" subtitle={`${formatNumber(data?.issues?.open_issue_total)} 个开放问题`}>
<Chart option={issueCodeOption} className="statistics-chart-md" emptyText="当前无开放问题" />
</Section>
</section>
<section className="statistics-bottom-grid">
<Section title="最近生产批次" subtitle="D-InSAR 运行记录">
<ProductionRunList rows={data?.production?.recent_runs} />
</Section>
<Section title="最近扫描状态" subtitle="资产接入任务">
<InventoryStateList rows={data?.inventory?.states} />
</Section>
</section>
<footer className="statistics-footer-note">
<span>页面不自动轮询,避免统计聚合影响生产服务。</span>
{lastLoadedAt && <span>本地刷新时间 {formatDateTime(lastLoadedAt.toISOString())}</span>}
</footer>
</>
)}
</div>
</div>
);
}