diff --git a/Cargo.lock b/Cargo.lock index c4493be..3bfd358 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,8 @@ dependencies = [ "clap", "dashmap", "dotenvy", + "fitsio", + "fitsio-sys", "flate2", "futures-util", "git2", @@ -243,6 +245,15 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "autotools" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef941527c41b0fc0dd48511a8154cd5fc7e29200a0ff8b7203c5d777dbc795cf" +dependencies = [ + "cc", +] + [[package]] name = "axum" version = "0.7.9" @@ -1207,6 +1218,26 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fitsio" +version = "0.21.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a7b7eee7f284a95437774d1152e04706f690d549687004ab7ae73cc776d08" +dependencies = [ + "fitsio-sys", + "libc", +] + +[[package]] +name = "fitsio-sys" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14929d0b6fa44a87b374a138ea2256c02c1b5beae1dbc98f62a3e132f862fed1" +dependencies = [ + "autotools", + "pkg-config", +] + [[package]] name = "flate2" version = "1.1.9" diff --git a/Cargo.toml b/Cargo.toml index 739a20b..01d80ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,8 @@ walkdir = "2" lru = "0.12" git2 = { version = "0.18", default-features = false, features = ["vendored-libgit2"] } dashmap = "6" +fitsio = { version = "0.21", features = ["fitsio-src"] } # FITS 解析;fitsio-src 自带 CFITSIO 源码编译,无需系统装 cfitsio +fitsio-sys = { version = "0.5", features = ["fitsio-src"] } # 直接访问 CFITSIO C API(读 Gaia XP 变长数组列 PD(55),fitsio crate 不支持) [features] default = [] diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 0df1ae0..38f2ef9 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -683,6 +683,14 @@ export default function App() { downloadSelected={observation.downloadSelected} downloadByIds={observation.downloadByIds} downloadByCoordinates={observation.downloadByCoordinates} + unifiedResult={observation.unifiedResult} + unifiedSearching={observation.unifiedSearching} + unifiedError={observation.unifiedError} + runUnifiedSearch={observation.runUnifiedSearch} + resolveNames={observation.resolveNames} + unifiedSelected={observation.unifiedSelected} + toggleUnifiedSelect={observation.toggleUnifiedSelect} + downloadUnifiedSelected={observation.downloadUnifiedSelected} libraryItems={observation.libraryItems} libraryTotal={observation.libraryTotal} libraryLoading={observation.libraryLoading} diff --git a/dashboard/src/components/observation/ObservationPreviewRenderer.tsx b/dashboard/src/components/observation/ObservationPreviewRenderer.tsx new file mode 100644 index 0000000..b3202ea --- /dev/null +++ b/dashboard/src/components/observation/ObservationPreviewRenderer.tsx @@ -0,0 +1,61 @@ +// dashboard/src/components/observation/ObservationPreviewRenderer.tsx +// +// 预览渲染分发器 —— 按 ObservationPreview.kind 路由到对应绘图组件 +// +// 扩展新产品类型的可视化时: +// 1. 实现 XxxPlot 组件(如 LightCurvePlot.tsx) +// 2. 在此 switch 加 case(从联合类型窄化出对应变体) +// 3. 在 constants.ts 的 canPreview() 放开该 product +// 现有 Spectrum 渲染无需改动(OCP:对扩展开放,对修改关闭)。 + +import type { ObservationPreview } from './constants'; +import { SpectrumPlot } from './SpectrumPlot'; + +interface ObservationPreviewRendererProps { + preview: ObservationPreview; +} + +export function ObservationPreviewRenderer({ + preview, +}: ObservationPreviewRendererProps) { + switch (preview.kind) { + case 'spectrum': + // 窄化:preview 此处为 { kind: 'spectrum' } & SpectrumPreview + return ; + + case 'lightcurve': + // TODO: 实现 LightCurvePlot 后替换占位 + return ( +
+ 光变曲线预览(开发中) +
+ ); + + case 'photometry': + // TODO: 实现 PhotometryTable 后替换占位 + return ( +
+ 测光预览(开发中) +
+ ); + + case 'image': + // image 变体已有 preview_data_url,直接渲染 + return ( + cutout + ); + + default: + // 穷尽性检查:未来新增 kind 会在编译期报错 + return ( +
+ 未知预览类型 +
+ ); + } +} diff --git a/dashboard/src/components/observation/ObservationResultCard.tsx b/dashboard/src/components/observation/ObservationResultCard.tsx index 9827fa2..35a1693 100644 --- a/dashboard/src/components/observation/ObservationResultCard.tsx +++ b/dashboard/src/components/observation/ObservationResultCard.tsx @@ -7,13 +7,24 @@ // 2. (可选)SpecialToolRenderers 的 FindObservationCard 可改为薄封装调用本组件 // // 视觉与 SpecialToolRenderers::FindObservationCard 保持一致,统一数据源主题色。 -import { Activity, CheckCircle2, AlertTriangle, Download } from 'lucide-react'; +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; @@ -34,6 +45,10 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) { 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>({}); + return (
{/* 标题栏:数据源 + 产品类型双标签 */} @@ -103,30 +118,84 @@ export function ObservationResultCard({ result }: ObservationResultCardProps) { )}
{/* 多 artifact 展示(Gaia 光变 G/BP/RP 三波段) */} - {p.artifacts.map((a, ai) => ( -
- - {a.band && ( - - {a.band} + {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 ( +
+
+ + {a.band && ( + + {a.band} + + )} + {a.file_format.toUpperCase()} ·{' '} + {formatFileSize(a.size_bytes)} + + {previewable && ( + + )} + + + {a.file_format.toUpperCase()} + + +
+ {/* 预览图(按 product 类型分发渲染) */} + {previewable && isOpen && ( +
+ {previewState.error ? ( +

+ {previewState.error} +

+ ) : previewState.data ? ( + + ) : previewState.loading ? ( +
+ + 解析 FITS 中... +
+ ) : null} +
)} - {a.file_format.toUpperCase()} · {formatFileSize(a.size_bytes)} - - - - {a.file_format.toUpperCase()} - -
- ))} +
+ ); + })} ); })} diff --git a/dashboard/src/components/observation/SpectrumPlot.tsx b/dashboard/src/components/observation/SpectrumPlot.tsx new file mode 100644 index 0000000..05a1502 --- /dev/null +++ b/dashboard/src/components/observation/SpectrumPlot.tsx @@ -0,0 +1,308 @@ +// dashboard/src/components/observation/SpectrumPlot.tsx +// +// 光谱预览 SVG 折线图 —— 手写内联 SVG,不引入第三方绘图库 +// (对齐项目 CitationGalaxyCanvas 手绘 canvas 先例,保持零额外依赖) +// +// 功能: +// - 多段叠加(Gaia BP/RP、DESI B/R/Z 各色) +// - hover 显示像素值(波长/流量) +// - 自适应宽度(响应式 viewBox) +// - 简洁坐标轴 + 网格线 + +import { useState, useMemo } from 'react'; +import type { SpectrumSegment } from './constants'; + +interface SpectrumPlotProps { + segments: SpectrumSegment[]; + height?: number; +} + +// 数据源 → 段配色(与 SOURCE_THEME 协调的线条色) +const SEGMENT_COLORS: Record = { + combined: '#64748b', // slate-500 + xp_merged: '#0ea5e9', // sky-500 + BP: '#0284c7', // sky-600 + RP: '#0369a1', // sky-700 + B: '#6366f1', // indigo-500 + R: '#8b5cf6', // violet-500 + Z: '#a855f7', // purple-500 + apstar_combined: '#6366f1', +}; + +const DEFAULT_COLOR = '#64748b'; + +interface PlotPoint { + wl: number; + flux: number; + segIdx: number; +} + +export function SpectrumPlot({ segments, height = 180 }: SpectrumPlotProps) { + const [hover, setHover] = useState(null); + + // 合并所有段的数据点,计算全局范围 + const { points, wlMin, wlMax, fluxMin, fluxMax } = useMemo(() => { + const pts: PlotPoint[] = []; + let wMin = Infinity; + let wMax = -Infinity; + let fMin = Infinity; + let fMax = -Infinity; + segments.forEach((seg, si) => { + const n = Math.min(seg.wavelength.length, seg.flux.length); + for (let i = 0; i < n; i++) { + const wl = seg.wavelength[i]; + const fl = seg.flux[i]; + if (!isFinite(wl) || !isFinite(fl)) continue; + pts.push({ wl, flux: fl, segIdx: si }); + if (wl < wMin) wMin = wl; + if (wl > wMax) wMax = wl; + if (fl < fMin) fMin = fl; + if (fl > fMax) fMax = fl; + } + }); + // flux 范围留 5% padding,避免曲线贴顶/底 + const fPad = (fMax - fMin) * 0.05 || Math.abs(fMax) * 0.1 || 1; + return { + points: pts, + wlMin: wMin, + wlMax: wMax, + fluxMin: fMin - fPad, + fluxMax: fMax + fPad, + }; + }, [segments]); + + if (points.length === 0) { + return
无有效数据点
; + } + + const width = 600; + const padL = 50; + const padR = 12; + const padT = 8; + const padB = 24; + const plotW = width - padL - padR; + const plotH = height - padT - padB; + + const xScale = (wl: number) => + padL + ((wl - wlMin) / (wlMax - wlMin || 1)) * plotW; + const yScale = (fl: number) => + padT + (1 - (fl - fluxMin) / (fluxMax - fluxMin || 1)) * plotH; + + // 每段生成 path + const paths = segments.map((seg) => { + const n = Math.min(seg.wavelength.length, seg.flux.length); + let d = ''; + for (let i = 0; i < n; i++) { + const wl = seg.wavelength[i]; + const fl = seg.flux[i]; + if (!isFinite(wl) || !isFinite(fl)) continue; + d += `${i === 0 ? 'M' : 'L'}${xScale(wl).toFixed(1)},${yScale(fl).toFixed(1)} `; + } + return { + d, + color: SEGMENT_COLORS[seg.band] ?? DEFAULT_COLOR, + band: seg.band, + }; + }); + + // 坐标轴刻度 + const xTicks = 5; + const yTicks = 4; + const xTickVals = Array.from( + { length: xTicks }, + (_, i) => wlMin + ((wlMax - wlMin) * i) / (xTicks - 1) + ); + const yTickVals = Array.from( + { length: yTicks }, + (_, i) => fluxMin + ((fluxMax - fluxMin) * i) / (yTicks - 1) + ); + + const fmtWl = (v: number) => { + if (v >= 1000) return `${(v / 1000).toFixed(2)}k`; + return v.toFixed(0); + }; + const fmtFlux = (v: number) => { + const abs = Math.abs(v); + if (abs !== 0 && (abs < 0.01 || abs >= 1e4)) return v.toExponential(1); + return v.toFixed(2); + }; + + return ( +
+ setHover(null)} + > + {/* 网格线 */} + {yTickVals.map((v, i) => ( + + ))} + {xTickVals.map((v, i) => ( + + ))} + + {/* 光谱曲线 */} + {paths.map((p, i) => ( + + ))} + + {/* X 轴刻度标签 */} + {xTickVals.map((v, i) => ( + + {fmtWl(v)} + + ))} + {/* Y 轴刻度标签 */} + {yTickVals.map((v, i) => ( + + {fmtFlux(v)} + + ))} + + {/* 轴标题 */} + + λ ({segments[0]?.wavelength_unit ?? 'Å'}) + + + flux ({segments[0]?.flux_unit ?? ''}) + + + {/* hover 指示 */} + {hover && ( + <> + + + + )} + + {/* 透明捕获层:hover 找最近点 */} + { + const rect = (e.target as SVGRectElement).getBoundingClientRect(); + const px = ((e.clientX - rect.left) / rect.width) * plotW; + // 反推波长 + const wl = wlMin + (px / plotW) * (wlMax - wlMin); + // 找最近点 + let nearest = points[0]; + let minDist = Infinity; + for (const p of points) { + const d = Math.abs(p.wl - wl); + if (d < minDist) { + minDist = d; + nearest = p; + } + } + setHover(nearest); + }} + /> + + + {/* hover tooltip */} + {hover && ( +
+ λ={hover.wl.toFixed(1)} · f={fmtFlux(hover.flux)} +
+ )} + + {/* 多段图例 */} + {segments.length > 1 && ( +
+ {segments.map((seg, i) => ( + + + {seg.band} + + ))} +
+ )} +
+ ); +} diff --git a/dashboard/src/components/observation/UnifiedSearchPanel.tsx b/dashboard/src/components/observation/UnifiedSearchPanel.tsx new file mode 100644 index 0000000..883ef2b --- /dev/null +++ b/dashboard/src/components/observation/UnifiedSearchPanel.tsx @@ -0,0 +1,793 @@ +// dashboard/src/components/observation/UnifiedSearchPanel.tsx +// +// 多目标统一全源检索视图 —— 跨多个目标 × 多个 (source, product) 并发 cone 检索 +// +// 两阶段流程: +// ① 统一检索:三模式输入目标(坐标列表 / 天体名称列表 / CSV 导入)+ 源 chip 筛选 +// → POST /api/observation/unified/search → 按 (source, product) 分组展示候选源 +// ② 批量下载:勾选候选源 → 逐 (source, product) 组调用现有 POST /observation/download +// +// 与 ObservationPanel.SearchView 的区别: +// - SearchView:单目标 + 单源 + 单产品(精细化参数) +// - UnifiedSearchPanel:多目标 + 多源(广覆盖发现) +import { useState, useMemo, useCallback } from 'react'; +import { + Globe, + Search, + Upload, + CheckCircle2, + AlertTriangle, + Download, + Loader2, + MapPin, + ListPlus, +} from 'lucide-react'; +import { ObservationResultCard } from './ObservationResultCard'; +import { + SOURCE_THEME, + PRODUCT_LABEL, + type UnifiedSearchRequest, + type UnifiedSearchResult, + type SourceCandidateGroup, + type TargetRequest, + type ResolvedTarget, + type ProductSpec, + type ObservationBatchResult, +} from './constants'; +import type { CapabilitySpec } from '../../types'; + +// ── 输入模式 ── +type InputMode = 'coordinates' | 'names' | 'csv'; + +interface UnifiedSearchPanelProps { + capabilities: CapabilitySpec[]; + // 统一检索状态(来自 useObservation) + unifiedResult: UnifiedSearchResult | null; + unifiedSearching: boolean; + unifiedError: string | null; + runUnifiedSearch: (req: UnifiedSearchRequest) => Promise; + resolveNames: (names: string[]) => Promise; + unifiedSelected: Set; + toggleUnifiedSelect: (key: string) => void; + downloadUnifiedSelected: () => Promise; + downloading: boolean; +} + +export function UnifiedSearchPanel({ + capabilities, + unifiedResult, + unifiedSearching, + unifiedError, + runUnifiedSearch, + resolveNames, + unifiedSelected, + toggleUnifiedSelect, + downloadUnifiedSelected, + downloading, +}: UnifiedSearchPanelProps) { + // ── 输入模式 ── + const [inputMode, setInputMode] = useState('coordinates'); + // ── 坐标列表文本(每行 ra,dec[,radius] 或 ra dec [radius])── + const [coordsText, setCoordsText] = useState(''); + // ── 天体名称列表文本 ── + const [namesText, setNamesText] = useState(''); + const [resolving, setResolving] = useState(false); + const [resolved, setResolved] = useState([]); + // ── CSV 导入 ── + // ── 公共参数 ── + const [release, setRelease] = useState(''); + const [defaultRadius, setDefaultRadius] = useState('0.1'); + const [perTargetLimit, setPerTargetLimit] = useState('50'); + // ── 源筛选:勾选的 (source, product) 组合 key ── + // 默认全选;key = `${source}|${product}|${subtype ?? ''}` + const allKeys = useMemo( + () => capabilities.map((c) => makeSourceKey(c.source, c.product)), + [capabilities] + ); + const [selectedSources, setSelectedSources] = useState>( + new Set(allKeys) + ); + // 同步:capabilities 加载后默认全选 + const effectiveSelected = useMemo(() => { + if (selectedSources.size === 0 && allKeys.length > 0) + return new Set(allKeys); + return selectedSources; + }, [selectedSources, allKeys]); + + // 解析目标列表(坐标模式 / 名称模式已解析 / CSV) + const targets: TargetRequest[] = useMemo(() => { + if (inputMode === 'names') { + // 名称模式:用 resolved 列表中成功的项 + return resolved + .filter((r) => r.ra != null && r.dec != null) + .map((r) => ({ + ra: r.ra!, + dec: r.dec!, + radius_deg: parseFloat(defaultRadius) || 0.1, + label: r.name, + })); + } + // coordinates / csv 模式:从文本解析 + return parseCoordsText(coordsText, parseFloat(defaultRadius) || 0.1); + }, [inputMode, resolved, coordsText, defaultRadius]); + + // ── 执行检索 ── + const handleSearch = useCallback(async () => { + if (targets.length === 0) return; + // 收集勾选的 (source, ProductSpec) + const selectedCaps = capabilities.filter((c) => + effectiveSelected.has(makeSourceKey(c.source, c.product)) + ); + // 按 (source, product) 合并 subtype(list_all_keys 后端会展开,前端用 product 级粒度) + const sources: [string, ProductSpec][] | undefined = + selectedCaps.length === allKeys.length + ? undefined // 全选 = 不传 sources(后端用默认全源) + : selectedCaps.map((c) => [c.source, { product: c.product }]); + + await runUnifiedSearch({ + targets, + sources, + release: release || undefined, + per_target_limit: parseInt(perTargetLimit) || 50, + }); + }, [ + targets, + capabilities, + effectiveSelected, + allKeys.length, + release, + perTargetLimit, + runUnifiedSearch, + ]); + + // ── 名称解析 ── + const handleResolveNames = useCallback(async () => { + const names = namesText + .split(/[\s,;\n]+/) + .map((s) => s.trim()) + .filter(Boolean); + if (names.length === 0) return; + setResolving(true); + try { + const results = await resolveNames(names); + setResolved(results); + } finally { + setResolving(false); + } + }, [namesText, resolveNames]); + + // ── CSV 导入(前端纯解析,列名含 ra/dec/name/radius)── + const handleCsvUpload = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const text = String(reader.result ?? ''); + const parsed = parseCsvTargets(text, parseFloat(defaultRadius) || 0.1); + // 把解析结果转成 coordsText 格式(统一走坐标文本流) + const lines = parsed.map((t) => + [t.ra.toString(), t.dec.toString(), t.label].filter(Boolean).join(',') + ); + setCoordsText(lines.join('\n')); + setInputMode('coordinates'); + }; + reader.readAsText(file); + e.target.value = ''; // 允许重复选同文件 + }, + [defaultRadius] + ); + + // ── 源筛选控制 ── + const toggleSource = useCallback((key: string) => { + setSelectedSources((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + const selectAllSources = useCallback( + () => setSelectedSources(new Set(allKeys)), + [allKeys] + ); + const clearSources = useCallback(() => setSelectedSources(new Set()), []); + + // 按产品分组的 capabilities(chip 展示) + const capsByProduct = useMemo(() => { + const map = new Map(); + for (const c of capabilities) { + if (!map.has(c.product)) map.set(c.product, []); + map.get(c.product)!.push(c); + } + return map; + }, [capabilities]); + + return ( +
+ {/* ── 输入模式切换 ── */} +
+ setInputMode('coordinates')} + icon={} + label="坐标列表" + /> + setInputMode('names')} + icon={} + label="天体名称" + /> + setInputMode('csv')} + icon={} + label="CSV 导入" + /> +
+ + {/* ── 目标输入区 ── */} +
+ {inputMode === 'coordinates' && ( + <> + +