AstroResearch/dashboard/src/hooks/useObservation.ts
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

542 lines
18 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/hooks/useObservation.ts
//
// 观测数据 Hook —— 检索 / 下载 / 缓存库浏览 / 能力清单 四块状态
//
// 后端端点(检索逻辑全部在后端):
// GET /api/observation/capabilities —— 返回支持的 (source × product × subtypes) 组合
// GET /api/observation/search —— 按坐标 cone 检索候选源(不下载)
// POST /api/observation/download —— 按坐标或标识符下载(写入 observation_cache
// GET /api/observation/list —— 列出已缓存条目(服务端分页 + 筛选)
//
// 前端只负责表单提交和结果渲染,不做任何筛选/分页/排序计算。
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import axios from 'axios';
import { extractErrorMessage } from '../utils/apiError';
import type { ObservationRecord, Candidate, CapabilitySpec } from '../types';
import type {
ObservationBatchResult,
UnifiedSearchRequest,
UnifiedSearchResult,
ResolvedTarget,
ProductSpec,
} from '../components/observation/constants';
interface UseObservationProps {
isAuthenticated: boolean | null;
}
// ── 检索表单状态 ──
export interface SearchForm {
source: string;
product: string;
subtype: string;
ra: string;
dec: string;
radius: string;
release: string;
/// 数据发布的子版本(仅 LAMOST 有意义);空串表示用该 DR 的默认子版本
version: string;
}
export const EMPTY_SEARCH_FORM: SearchForm = {
source: 'lamost',
product: 'spectrum',
subtype: '',
ra: '',
dec: '',
radius: '0.1',
release: '',
version: '',
};
export function useObservation({ isAuthenticated }: UseObservationProps) {
// ════════════════════════════════════════════════════
// A. 能力清单(启动时加载一次)
// ════════════════════════════════════════════════════
const [capabilities, setCapabilities] = useState<CapabilitySpec[]>([]);
const capabilitiesLoadedRef = useRef(false);
const fetchCapabilities = useCallback(async () => {
if (capabilitiesLoadedRef.current) return;
try {
const res = await axios.get<CapabilitySpec[]>(
'/api/observation/capabilities'
);
setCapabilities(res.data ?? []);
capabilitiesLoadedRef.current = true;
} catch (e) {
console.error('加载观测数据能力清单失败', e);
}
}, []);
useEffect(() => {
if (isAuthenticated !== true) return;
fetchCapabilities();
}, [isAuthenticated, fetchCapabilities]);
// ════════════════════════════════════════════════════
// B. 检索search—— 坐标 cone 检索候选源
// ════════════════════════════════════════════════════
const [searchForm, setSearchForm] = useState<SearchForm>(EMPTY_SEARCH_FORM);
// 当前 (source, product) 对应的能力描述(供前端表单派生选项与联动校验)
const currentSpec = useMemo<CapabilitySpec | undefined>(() => {
return capabilities.find(
(c) => c.source === searchForm.source && c.product === searchForm.product
);
}, [capabilities, searchForm.source, searchForm.product]);
// 当 source/product 切换后:
// 1. 若当前 release 不在新源的版本列表里,则回落到默认版本
// 2. 若当前 radius 超出硬性上限,则回落到建议上限(而非硬上限,避免误导)
useEffect(() => {
if (!currentSpec) return;
const valid = currentSpec.releases;
const hardMax = currentSpec.hard_max_radius_deg;
const suggested = currentSpec.suggested_max_radius_deg;
setSearchForm((f) => {
let next = f;
// release 回落(仅在该源区分版本时)
if (valid.length > 0 && (!f.release || !valid.includes(f.release))) {
next = { ...next, release: currentSpec.default_release ?? valid[0] };
}
// radius 回落:超出硬性上限时回到建议值(不回到硬上限,避免默认就触发警告)
const cur = parseFloat(f.radius);
if (!isNaN(cur) && cur > hardMax) {
next = { ...next, radius: String(suggested) };
}
return next;
});
}, [currentSpec]);
// 当 release 切换后:若该 release 有子版本列表,则校验当前 version 是否在列表里,
// 不在则清空清空后端用默认子版本。无子版本概念的源release_versions 为空)也清空。
useEffect(() => {
if (!currentSpec) return;
const versionsMap = currentSpec.release_versions ?? {};
const versions = searchForm.release
? versionsMap[searchForm.release]
: undefined;
setSearchForm((f) => {
if (!versions || versions.length === 0) {
// 该 release 无子版本概念
return f.version ? { ...f, version: '' } : f;
}
if (!f.version || !versions.includes(f.version)) {
return { ...f, version: '' }; // 清空 = 用默认(最新公开)子版本
}
return f;
});
}, [currentSpec, searchForm.release]);
const [searchResults, setSearchResults] = useState<Candidate[]>([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState<string | null>(null);
const [selectedSourceIds, setSelectedSourceIds] = useState<Set<string>>(
new Set()
);
const runSearch = useCallback(async () => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setSearchError('请输入有效的 ra / dec 坐标');
setSearchResults([]);
return;
}
setSearching(true);
setSearchError(null);
setSelectedSourceIds(new Set());
try {
const res = await axios.get<Candidate[]>('/api/observation/search', {
params: {
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
ra,
dec,
radius: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
release: searchForm.release || undefined,
version: searchForm.version || undefined,
},
});
setSearchResults(res.data ?? []);
} catch (e: unknown) {
setSearchError(
extractErrorMessage(e, '检索失败,请检查坐标与数据源参数')
);
setSearchResults([]);
} finally {
setSearching(false);
}
}, [searchForm]);
const toggleSelect = useCallback((sourceId: string) => {
setSelectedSourceIds((prev) => {
const next = new Set(prev);
if (next.has(sourceId)) next.delete(sourceId);
else next.add(sourceId);
return next;
});
}, []);
const selectAll = useCallback(() => {
setSelectedSourceIds(new Set(searchResults.map((c) => c.source_id)));
}, [searchResults]);
const selectNone = useCallback(() => {
setSelectedSourceIds(new Set());
}, []);
// ════════════════════════════════════════════════════
// C. 下载download—— 按坐标或标识符
// ════════════════════════════════════════════════════
const [downloading, setDownloading] = useState(false);
const [downloadResult, setDownloadResult] =
useState<ObservationBatchResult | null>(null);
const [downloadError, setDownloadError] = useState<string | null>(null);
/** 按标识符列表下载 */
const downloadByIds = useCallback(
async (sourceIds: string[]) => {
if (sourceIds.length === 0) return;
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
version: searchForm.version || undefined,
force: false,
mode: 'identifiers',
source_ids: sourceIds,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
/** 下载选中的候选源(标识符模式) */
const downloadSelected = useCallback(async () => {
const ids = Array.from(selectedSourceIds);
if (ids.length === 0) return;
await downloadByIds(ids);
}, [selectedSourceIds, downloadByIds]);
/** 按坐标下载cone 检索 + 全部下载) */
const downloadByCoordinates = useCallback(
async (strategy: 'nearest' | 'all' = 'nearest') => {
const ra = parseFloat(searchForm.ra);
const dec = parseFloat(searchForm.dec);
if (isNaN(ra) || isNaN(dec)) {
setDownloadError('请输入有效的 ra / dec 坐标');
return;
}
setDownloading(true);
setDownloadError(null);
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: searchForm.source,
product: searchForm.product,
subtype: searchForm.subtype || undefined,
release: searchForm.release || undefined,
version: searchForm.version || undefined,
force: false,
mode: 'coordinates',
ra,
dec,
radius_deg: Math.max(0.0001, parseFloat(searchForm.radius) || 0.1),
strategy,
}
);
setDownloadResult(res.data);
} catch (e: unknown) {
setDownloadError(extractErrorMessage(e, '下载失败,请稍后重试'));
} finally {
setDownloading(false);
}
},
[searchForm]
);
// ════════════════════════════════════════════════════
// D. 统一全源检索unified—— 多目标 × 多源并发 cone 检索
// ════════════════════════════════════════════════════
const [unifiedResult, setUnifiedResult] =
useState<UnifiedSearchResult | null>(null);
const [unifiedSearching, setUnifiedSearching] = useState(false);
const [unifiedError, setUnifiedError] = useState<string | null>(null);
// 勾选的候选源key = `${source}#${product}#${subtype}#${source_id}`
const [unifiedSelected, setUnifiedSelected] = useState<Set<string>>(
new Set()
);
/** 统一检索:跨目标 × 跨源并发 cone 检索 → 聚合候选源 */
const runUnifiedSearch = useCallback(async (req: UnifiedSearchRequest) => {
setUnifiedSearching(true);
setUnifiedError(null);
setUnifiedSelected(new Set());
try {
const res = await axios.post<UnifiedSearchResult>(
'/api/observation/unified/search',
req
);
setUnifiedResult(res.data);
} catch (e: unknown) {
setUnifiedError(
extractErrorMessage(e, '统一检索失败,请检查目标与源参数')
);
setUnifiedResult(null);
} finally {
setUnifiedSearching(false);
}
}, []);
/** 批量解析天体名称为坐标(供「天体名称列表」输入模式) */
const resolveNames = useCallback(
async (names: string[]): Promise<ResolvedTarget[]> => {
if (names.length === 0) return [];
try {
const res = await axios.post<ResolvedTarget[]>(
'/api/observation/unified/resolve',
{ names }
);
return res.data ?? [];
} catch (e: unknown) {
// 整体失败时,把每个名称标记为失败,便于前端展示
return names.map((n) => ({
name: n,
error: extractErrorMessage(e, '名称解析服务不可用'),
}));
}
},
[]
);
/** 切换某个候选源的勾选状态 */
const toggleUnifiedSelect = useCallback((key: string) => {
setUnifiedSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}, []);
/**
* 批量下载统一检索中勾选的候选源。
* 按 (source, product) 分组,逐组调用现有 POST /observation/downloadmode=identifiers
* 返回每组的下载结果(前端用多个 ObservationResultCard 展示)。
*/
const downloadUnifiedSelected = useCallback(async () => {
if (unifiedSelected.size === 0 || !unifiedResult) return [];
// 收集 key → {source, product, subtype, source_id}
const groups = new Map<
string,
{ spec: [string, ProductSpec]; ids: string[] }
>();
for (const key of unifiedSelected) {
const [source, product, subtype, ...idParts] = key.split('#');
const source_id = idParts.join('#'); // source_id 理论上不含 #,但保守处理
const groupKey = `${source}#${product}#${subtype}`;
if (!groups.has(groupKey)) {
groups.set(groupKey, {
spec: [source, { product, subtype: subtype || undefined }],
ids: [],
});
}
groups.get(groupKey)!.ids.push(source_id);
}
const results: ObservationBatchResult[] = [];
setDownloading(true);
setDownloadError(null);
try {
for (const { spec, ids } of groups.values()) {
try {
const res = await axios.post<ObservationBatchResult>(
'/api/observation/download',
{
source: spec[0],
product: spec[1].product,
subtype: spec[1].subtype || undefined,
force: false,
mode: 'identifiers',
source_ids: ids,
}
);
results.push(res.data);
} catch (e: unknown) {
// 单组失败不中断,记录为 failures 占位 batch
results.push({
source: spec[0],
product: spec[1],
matched_count: 0,
products: [],
failures: ids.map((id) => ({
source_label: id,
error: extractErrorMessage(e, '下载失败'),
})),
});
}
}
// 把最后一组结果存入 downloadResult 供 ObservationResultCard 展示
if (results.length > 0) {
setDownloadResult(results[results.length - 1]);
}
} finally {
setDownloading(false);
}
return results;
}, [unifiedSelected, unifiedResult]);
// ════════════════════════════════════════════════════
// E. 缓存库library—— 服务端分页浏览已下载数据
// ════════════════════════════════════════════════════
const [libraryItems, setLibraryItems] = useState<ObservationRecord[]>([]);
const [libraryTotal, setLibraryTotal] = useState(0);
const [libraryLoading, setLibraryLoading] = useState(false);
const [libraryError, setLibraryError] = useState<string | null>(null);
// 筛选状态(提交给后端,不在前端筛)
const [libSource, setLibSource] = useState<string>('all');
const [libProduct, setLibProduct] = useState<string>('all');
const [libSearch, setLibSearch] = useState('');
const [libSort, setLibSort] = useState<'created' | 'source' | 'product'>(
'created'
);
const [libPage, setLibPage] = useState(1);
const [libPageSize, setLibPageSize] = useState(12);
const fetchLibrary = useCallback(async () => {
if (isAuthenticated !== true) return;
setLibraryLoading(true);
setLibraryError(null);
try {
const res = await axios.get<{
items: ObservationRecord[];
total: number;
}>('/api/observation/list', {
params: {
source: libSource !== 'all' ? libSource : undefined,
product: libProduct !== 'all' ? libProduct : undefined,
q: libSearch.trim() || undefined,
sort: libSort,
limit: libPageSize,
offset: (libPage - 1) * libPageSize,
},
});
setLibraryItems(res.data.items ?? []);
setLibraryTotal(res.data.total ?? 0);
} catch (e: unknown) {
setLibraryError(extractErrorMessage(e, '加载缓存库失败,请检查后端连接'));
setLibraryItems([]);
} finally {
setLibraryLoading(false);
}
}, [
isAuthenticated,
libSource,
libProduct,
libSearch,
libSort,
libPage,
libPageSize,
]);
// 任意筛选/分页变更 → 重新请求后端
useEffect(() => {
fetchLibrary();
}, [fetchLibrary]);
const resetLibraryFilters = useCallback(() => {
setLibSource('all');
setLibProduct('all');
setLibSearch('');
setLibSort('created');
setLibPage(1);
}, []);
return {
// A. 能力清单
capabilities,
currentSpec,
fetchCapabilities,
// B. 检索
searchForm,
setSearchForm,
searchResults,
searching,
searchError,
runSearch,
selectedSourceIds,
toggleSelect,
selectAll,
selectNone,
// C. 下载
downloading,
downloadResult,
downloadError,
setDownloadResult,
downloadSelected,
downloadByIds,
downloadByCoordinates,
// D. 统一全源检索
unifiedResult,
setUnifiedResult,
unifiedSearching,
unifiedError,
runUnifiedSearch,
resolveNames,
unifiedSelected,
toggleUnifiedSelect,
downloadUnifiedSelected,
// D. 缓存库
libraryItems,
libraryTotal,
libraryLoading,
libraryError,
fetchLibrary,
libSource,
setLibSource: (v: string) => {
setLibSource(v);
setLibPage(1);
},
libProduct,
setLibProduct: (v: string) => {
setLibProduct(v);
setLibPage(1);
},
libSearch,
setLibSearch: (v: string) => {
setLibSearch(v);
setLibPage(1);
},
libSort,
setLibSort: (v: 'created' | 'source' | 'product') => {
setLibSort(v);
setLibPage(1);
},
libPage,
setLibPage,
libPageSize,
setLibPageSize,
resetLibraryFilters,
};
}