AstroResearch/dashboard/src/components/observation/ObservationResultCard.tsx
Asfmq 8f1ed6d08c feat: 观测层跨源扩展——ZTF/TESS 时域 + Gaia XP 光谱还原 + FITS 预览与统一全源检索
后端(新增 ~4400 行):
- 新增 IRSA/IPAC、MAST 两客户端:ZTF 光变曲线(IRSA REST,CSV,免认证)、
  TESS 光变(MAST TIC cone search + LC FITS),均复用 SSRF 安全重定向 + 重试
- Gaia DR3 XP_CONTINUOUS 光谱还原(gaia_xp/):逆向 GaiaXPy calibrate() 算法,
  55 个 Hermite 系数 → 采样光谱;design_matrix 启动时 OnceLock 预计算一次。
  因 fitsio crate 遇 PD(55) 变长数组列会 panic,改用 fitsio-sys 直连 CFITSIO
  原生读取;新增 fitsio/fitsio-sys(vendored,无需系统 cfitsio)
- FITS 预览解析层(preview.rs):跨 LAMOST/SDSS/DESI/Gaia XP 的异构 FITS
  (BinTable/Image/Hermite 系数)统一归一化为 ObservationPreview JSON
  (Spectrum/LightCurve/Photometry/Image 标签枚举,OCP 可扩展)
- 测光 fetcher(photometry.rs):2MASS/AllWISE/Pan-STARRS/Gaia 四源,
  均复用 VizieR/Gaia TAP 查询、零新 HTTP 代码,配置驱动表名/列名差异
- 统一全源检索(unified.rs):在 dispatch 之上叠加多目标 × 多源并发 cone 扇出,
  失败隔离,两阶段(检索聚合 → 勾选后复用现有 download 端点批量下载)
- registry 注册 6 个新 fetcher + list_all_keys() 暴露全 (Source,ProductSpec)
  组合供前端细粒度勾选;Source 枚举增 5 个变体
- 新增 3 条路由:GET /observation/preview、POST /observation/unified/{search,resolve}

前端(新增 ~1600 行):
- UnifiedSearchPanel:三模式目标输入(坐标/天体名/CSV)+ 源 chip 筛选,
  按 (source,product) 分组展示候选源并支持勾选批量下载
- SpectrumPlot:手写内联 SVG 光谱折线图(零第三方绘图库),多段叠加 + hover 取值
- ObservationPreviewRenderer + useObservationPreview:预览渲染接入
- useObservation/ObservationResultCard/App 联动统一检索状态与下载链路
2026-07-09 00:20:29 +08:00

225 lines
8.6 KiB
TypeScript
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.

// dashboard/src/components/observation/ObservationResultCard.tsx
//
// 观测数据下载结果卡片 —— 可复用渲染 ObservationBatch
//
// 用途:
// 1. 观测数据面板的"检索下载"视图在下载完成后展示结果
// 2. 可选SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件
//
// 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。
import { useState } from 'react';
import {
Activity,
CheckCircle2,
AlertTriangle,
Download,
LineChart,
Loader2,
} from 'lucide-react';
import {
SOURCE_THEME,
PRODUCT_LABEL,
formatFileSize,
canPreview,
type ObservationBatchResult,
} from './constants';
import { ObservationPreviewRenderer } from './ObservationPreviewRenderer';
import { useObservationPreview } from './useObservationPreview';
interface ObservationResultCardProps {
result: ObservationBatchResult;
}
export function ObservationResultCard({ result }: ObservationResultCardProps) {
const {
source,
product,
ra,
dec,
radius_deg,
matched_count,
products = [],
failures = [],
} = result;
const theme = SOURCE_THEME[source] ?? SOURCE_THEME.lamost;
const productLabel = PRODUCT_LABEL[product.product] ?? product.product;
const hasResult = products.length > 0 || failures.length > 0;
// 预览:自包含 hook面板流 + Agent 流共用(两者都渲染本卡片)
const { fetchPreview, getPreview } = useObservationPreview();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
return (
<div className="bg-slate-50/60 border border-slate-200 rounded-lg p-3.5 space-y-3 text-xs shadow-2xs">
{/* 标题栏:数据源 + 产品类型双标签 */}
<div className="flex items-center justify-between border-b border-slate-200/70 pb-2">
<div className="flex items-center gap-1.5 font-bold text-slate-800 text-[11px] uppercase tracking-wide">
<Activity className="w-3.5 h-3.5 text-slate-500" />
<span></span>
</div>
<div className="flex items-center gap-1">
<span
className={`px-2 py-0.5 rounded text-[10px] font-bold ${theme.badge}`}
>
{theme.label}
</span>
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-200 text-slate-700">
{productLabel}
{product.subtype ? ` · ${product.subtype}` : ''}
</span>
</div>
</div>
{/* 查询信息 */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] text-slate-600">
{ra !== undefined && dec !== undefined && (
<span className="font-mono">
ra={ra.toFixed(4)} dec={dec.toFixed(4)}
{radius_deg !== undefined ? ` r=${radius_deg}°` : ''}
</span>
)}
<span className="bg-slate-200 text-slate-700 px-1.5 py-0.5 rounded font-medium">
{matched_count}
</span>
{products.length > 0 && (
<span className="flex items-center gap-0.5 text-emerald-600 font-medium">
<CheckCircle2 className="w-3 h-3" />
{products.length}
</span>
)}
{failures.length > 0 && (
<span className="flex items-center gap-0.5 text-rose-600 font-medium">
<AlertTriangle className="w-3 h-3" />
{failures.length}
</span>
)}
</div>
{/* 产物列表(每个 product 含 1~N 个 artifact */}
{products.map((p, i) => {
const allCached =
p.artifacts.length > 0 && p.artifacts.every((a) => a.cached);
return (
<div
key={i}
className="border border-slate-200 rounded bg-white p-2.5 space-y-1.5"
>
<div className="flex items-center justify-between">
<span className="font-mono font-medium text-slate-700">
{p.source_label}
</span>
{allCached ? (
<span className="flex items-center gap-0.5 text-[10px] text-emerald-600">
<CheckCircle2 className="w-3 h-3" />
</span>
) : (
<span className="text-[10px] text-slate-400"></span>
)}
</div>
{/* 多 artifact 展示Gaia 光变 G/BP/RP 三波段) */}
{p.artifacts.map((a, ai) => {
const previewKey = `${p.source_id}#${ai}`;
const previewable = canPreview(product.product, a.file_format);
const isOpen = expanded[previewKey] ?? false;
const previewState = getPreview(p.source_id, ai);
const togglePreview = () => {
const next = !isOpen;
setExpanded((m) => ({ ...m, [previewKey]: next }));
if (next && !previewState.data && !previewState.loading) {
fetchPreview(
source,
product.product,
product.subtype,
p.source_id,
ai
);
}
};
return (
<div key={ai} className="space-y-1">
<div className="flex items-center justify-between text-[10px] pl-2 border-l-2 border-slate-100">
<span className="text-slate-500 flex items-center gap-1">
{a.band && (
<span className="px-1 py-0.5 rounded bg-violet-100 text-violet-700 font-medium">
{a.band}
</span>
)}
{a.file_format.toUpperCase()} ·{' '}
{formatFileSize(a.size_bytes)}
</span>
<span className="flex items-center gap-1">
{previewable && (
<button
onClick={togglePreview}
className="flex items-center gap-0.5 font-medium text-slate-500 hover:text-slate-800 transition-colors"
title="预览"
>
{previewState.loading ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<LineChart className="w-3 h-3" />
)}
<span></span>
</button>
)}
<a
href={a.file_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-0.5 font-bold text-sky-600 hover:text-sky-800 hover:underline"
>
<Download className="w-3 h-3" />
<span>{a.file_format.toUpperCase()}</span>
</a>
</span>
</div>
{/* 预览图(按 product 类型分发渲染) */}
{previewable && isOpen && (
<div className="pl-2">
{previewState.error ? (
<p className="text-[10px] text-rose-500 py-1">
{previewState.error}
</p>
) : previewState.data ? (
<ObservationPreviewRenderer
preview={previewState.data}
/>
) : previewState.loading ? (
<div className="flex items-center gap-1 text-[10px] text-slate-400 py-4 justify-center">
<Loader2 className="w-3 h-3 animate-spin" />
FITS ...
</div>
) : null}
</div>
)}
</div>
);
})}
</div>
);
})}
{/* 失败列表 */}
{failures.map((f, i) => (
<div
key={`f${i}`}
className="border border-rose-200 rounded bg-rose-50/50 p-2.5"
>
<div className="flex items-center gap-1 text-rose-700 font-medium">
<AlertTriangle className="w-3 h-3" />
<span className="font-mono text-[10px]">{f.source_label}</span>
</div>
<p className="text-[10px] text-rose-600 mt-1 break-all">{f.error}</p>
</div>
))}
{!hasResult && (
<p className="text-[11px] text-slate-400 italic py-2">
({productLabel})
</p>
)}
</div>
);
}