diff --git a/src/App.tsx b/src/App.tsx index 4004e84..a15168c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,12 +47,7 @@ function App() { } /> - - - - } + element={} /> (null); + const additionalSourcesKey = JSON.stringify(additionalSources ?? []); useEffect(() => { if (!aladinDivRef.current || !window.A) return; try { + aladinDivRef.current.replaceChildren(); + const aladin = window.A.aladin(aladinDivRef.current, { survey, fov, @@ -45,12 +125,10 @@ export function AladinViewer({ if (additionalSources && additionalSources.length > 0) { const nameCatalog = window.A.catalog({ - labelColumn: "name", - shape: "cross", + shape: drawSourceWithLabel, color: "black", - displayLabel: true, - labelColor: "lightgrey", - labelFont: "14px sans-serif", + displayLabel: false, + sourceSize: SOURCE_SIZE, }); const descrCatalog = window.A.catalog({ color: "black", @@ -77,7 +155,7 @@ export function AladinViewer({ } catch (error) { console.error("Error initializing Aladin:", error); } - }, [ra, dec, fov, survey, additionalSources]); + }, [ra, dec, fov, survey, additionalSourcesKey]); return
; } @@ -112,12 +190,14 @@ declare global { addCatalog: (catalog: AladinCatalog) => void; }; catalog: (options?: { - labelColumn?: string; displayLabel?: boolean; - labelColor?: string; - labelFont?: string; sourceSize?: number; - shape?: string; + shape?: + | string + | (( + source: AladinCanvasSource, + ctx: CanvasRenderingContext2D, + ) => void); color?: string; }) => AladinCatalog; source: ( diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index f0100e4..7db9fe3 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -1,9 +1,8 @@ -import { ReactElement, useEffect, useState } from "react"; +import { ReactElement, ReactNode, useEffect, useState } from "react"; import { useParams } from "react-router-dom"; import { AladinViewer } from "../components/core/Aladin"; import { Loading } from "../components/core/Loading"; import { ErrorPage } from "../components/ui/ErrorPage"; -import { CatalogData } from "../components/ui/CatalogData"; import { CommonTable, Column, @@ -18,6 +17,8 @@ import { RecordCrossmatch, PgcCandidate, Schema as AdminSchema, + StatusesPayload, + Catalogs, } from "../clients/admin/types.gen"; import { Schema as BackendSchema } from "../clients/backend/types.gen"; import { getResource } from "../resources/resources"; @@ -29,8 +30,13 @@ import { useDataFetching } from "../hooks/useDataFetching"; import { adminClient } from "../clients/config"; import { Button } from "../components/core/Button"; import { isLoggedIn } from "../auth/token"; +import { + Declination, + QuantityWithError, + RightAscension, +} from "../components/core/Astronomy"; +import classNames from "classnames"; -// TODO: remove when admin api uses the same structures as data api function convertAdminSchemaToBackendSchema( adminSchema: AdminSchema, ): BackendSchema { @@ -97,6 +103,10 @@ function createDescription( return parts.join(", "); } +function getCandidateLabel(candidate: PgcCandidate): string { + return candidate.catalogs?.designation?.name ?? `PGC ${candidate.pgc}`; +} + function convertCandidatesToAdditionalSources( candidates: PgcCandidate[], mainRecord: RecordCrossmatch, @@ -106,7 +116,7 @@ function convertCandidatesToAdditionalSources( .map((candidate) => ({ ra: candidate.catalogs!.coordinates!.equatorial.ra, dec: candidate.catalogs!.coordinates!.equatorial.dec, - label: `PGC ${candidate.pgc}`, + label: getCandidateLabel(candidate), description: createDescription( candidate.catalogs?.velocity?.heliocentric, candidate.catalogs?.redshift, @@ -128,8 +138,224 @@ function convertCandidatesToAdditionalSources( : candidateSources; } -interface RecordCrossmatchDetailsProps { - data: GetRecordCrossmatchResponse; +function ObjectSummary({ + catalogs, + schema, + name, + layout = "rows", +}: { + catalogs: Catalogs; + schema: BackendSchema; + name: ReactNode; + layout?: "rows" | "columnar"; +}): ReactElement { + const equatorial = catalogs?.coordinates?.equatorial; + const redshift = catalogs?.redshift; + + const nameField = ( + <> +
Name
+
{name}
+ + ); + + const raField = equatorial ? ( + <> +
RA
+
+ + + +
+ + ) : null; + + const decField = equatorial ? ( + <> +
Dec
+
+ + + +
+ + ) : null; + + const redshiftField = redshift ? ( + <> +
Redshift
+
+ + {redshift.z.toFixed(5)} + +
+ + ) : null; + + if (layout === "columnar") { + return ( +
+
{nameField}
+ {raField &&
{raField}
} + {decField &&
{decField}
} + {redshiftField &&
{redshiftField}
} +
+ ); + } + + return ( +
+ {nameField} + {raField} + {decField} + {redshiftField} +
+ ); +} + +type ResolutionChoice = "new" | number; + +interface ResolutionSelectorProps { + crossmatch: RecordCrossmatch; + candidates: PgcCandidate[]; + schema: BackendSchema; + showResolveControls: boolean; + resolving: ResolutionChoice | null; + selected: ResolutionChoice | null; + onSelect: (choice: ResolutionChoice) => void; + onSubmit: () => void; +} + +function ResolutionSelector({ + crossmatch, + candidates, + schema, + showResolveControls, + resolving, + selected, + onSelect, + onSubmit, +}: ResolutionSelectorProps): ReactElement { + const matchedPgc = + crossmatch.status === "existing" ? crossmatch.metadata.pgc : null; + + function renderCandidateSummary(candidate: PgcCandidate): ReactElement { + return ( + + {getCandidateLabel(candidate)} + + } + /> + ); + } + + if (!showResolveControls) { + return ( +
+ {candidates.length > 0 && ( +
+

Candidates

+ {candidates.map((candidate) => ( +
+ {renderCandidateSummary(candidate)} +
+ ))} +
+ )} + + {crossmatch.status === "new" && ( +

+ {getResource("crossmatch.action.mark_new").Title} +

+ )} +
+ ); + } + + return ( +
+
+

Resolution

+ + + {candidates.map((candidate) => ( + + ))} + + +
+
+ ); } function OriginalData({ @@ -148,10 +374,15 @@ function OriginalData({ return ; } +interface RecordCrossmatchDetailsProps { + data: GetRecordCrossmatchResponse; +} + function RecordCrossmatchDetails({ data, }: RecordCrossmatchDetailsProps): ReactElement { - const [resolvingPgc, setResolvingPgc] = useState(null); + const [selected, setSelected] = useState(null); + const [resolving, setResolving] = useState(null); const [resolveError, setResolveError] = useState(null); const { crossmatch, @@ -160,7 +391,6 @@ function RecordCrossmatchDetails({ table_name: tableName, original_data: originalData, } = data; - const recordCatalogs = crossmatch.catalogs; const showResolveControls = isLoggedIn() && crossmatch.triage_status === "pending"; const backendSchema = convertAdminSchemaToBackendSchema(schema); @@ -173,127 +403,140 @@ function RecordCrossmatchDetails({ const triageBadgeLabel = getResource( `crossmatch.triage.verbose.${crossmatch.triage_status}`, ).Title; + const objectName = + crossmatch.catalogs?.designation?.name ?? `Record ${crossmatch.record_id}`; + + async function submitCrossmatchResolution( + statuses: StatusesPayload, + ): Promise { + const response = await setCrossmatchResults({ + client: adminClient, + body: { statuses }, + }); + + if (response.error || !response.data?.data) { + throw new Error( + typeof response.error === "object" + ? JSON.stringify(response.error) + : String(response.error || "Unknown error"), + ); + } + + window.location.reload(); + } async function resolveCandidate(pgc: number): Promise { setResolveError(null); - setResolvingPgc(pgc); + setResolving(pgc); try { - const response = await setCrossmatchResults({ - client: adminClient, - body: { - statuses: { - existing: { - record_ids: [crossmatch.record_id], - pgcs: [pgc], - triage_statuses: ["resolved"], - }, - }, + await submitCrossmatchResolution({ + existing: { + record_ids: [crossmatch.record_id], + pgcs: [pgc], + triage_statuses: ["resolved"], }, }); + } catch (err) { + setResolveError(`${err}`); + } finally { + setResolving(null); + } + } - if (response.error || !response.data?.data) { - throw new Error( - typeof response.error === "object" - ? JSON.stringify(response.error) - : String(response.error || "Unknown error"), - ); - } - - window.location.reload(); + async function markAsNew(): Promise { + setResolveError(null); + setResolving("new"); + try { + await submitCrossmatchResolution({ + new: { + record_ids: [crossmatch.record_id], + triage_statuses: ["resolved"], + }, + }); } catch (err) { setResolveError(`${err}`); } finally { - setResolvingPgc(null); + setResolving(null); + } + } + + async function submitResolution(): Promise { + if (selected === null) return; + if (selected === "new") { + await markAsNew(); + } else { + await resolveCandidate(selected); } } return (
-
+
{crossmatch.catalogs?.coordinates?.equatorial && ( )} -
-

- - {crossmatch.catalogs?.designation?.name ?? - `Record ${crossmatch.record_id}`} - - - {triageBadgeLabel} - -

-

- Record ID:{" "} - - {crossmatch.record_id} - -

-

- Table: {tableName} -

-

- {candidates.length === 1 - ? "1 candidate" - : `${candidates.length} candidates`} -

+
+
+

+ {objectName} + + {triageBadgeLabel} + +

+

+ Record ID:{" "} + + {crossmatch.record_id} + +

+

+ Table: {tableName} +

+

+ {candidates.length === 1 + ? "1 candidate" + : `${candidates.length} candidates`} +

+
+
+

Object

+ +
- + void submitResolution()} + /> + + {resolveError && ( +

+ {resolveError} +

+ )} {originalData && ( )} - - {candidates.length > 0 && ( -
-

Crossmatch Candidates

- {resolveError && ( -

- {resolveError} -

- )} - {candidates.map((candidate) => ( - -
-

- - - {`PGC ${candidate.pgc}`} - - -

- {showResolveControls && ( - - )} -
- -
- ))} -
- )}
); }