后端(新增 ~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 联动统一检索状态与下载链路
542 lines
18 KiB
TypeScript
542 lines
18 KiB
TypeScript
// 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/download(mode=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,
|
||
};
|
||
}
|