From e90a559185c02957fe151b14237be315ebd118d0 Mon Sep 17 00:00:00 2001 From: kraysent Date: Fri, 22 May 2026 18:46:40 +0100 Subject: [PATCH 1/5] add "Mark as new" button to the interface --- src/assets/texts.json | 4 +- src/pages/RecordCrossmatchDetails.tsx | 96 +++++++++++++++++++-------- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/assets/texts.json b/src/assets/texts.json index 4ff3ef7..8e6604f 100644 --- a/src/assets/texts.json +++ b/src/assets/texts.json @@ -10,6 +10,8 @@ "crossmatch.triage.pending": "Pending", "crossmatch.triage.resolved": "Resolved", "crossmatch.triage.verbose.pending": "Pending manual check", - "crossmatch.triage.verbose.resolved": "Resolved" + "crossmatch.triage.verbose.resolved": "Resolved", + "crossmatch.action.mark_new": "Mark as new", + "crossmatch.action.resolve": "Match to this PGC" } } diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index f0100e4..abf82e1 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -18,6 +18,7 @@ import { RecordCrossmatch, PgcCandidate, Schema as AdminSchema, + StatusesPayload, } from "../clients/admin/types.gen"; import { Schema as BackendSchema } from "../clients/backend/types.gen"; import { getResource } from "../resources/resources"; @@ -151,7 +152,7 @@ function OriginalData({ function RecordCrossmatchDetails({ data, }: RecordCrossmatchDetailsProps): ReactElement { - const [resolvingPgc, setResolvingPgc] = useState(null); + const [resolving, setResolving] = useState<"new" | number | null>(null); const [resolveError, setResolveError] = useState(null); const { crossmatch, @@ -174,36 +175,57 @@ function RecordCrossmatchDetails({ `crossmatch.triage.verbose.${crossmatch.triage_status}`, ).Title; + 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); } } @@ -243,6 +265,19 @@ function RecordCrossmatchDetails({ ? "1 candidate" : `${candidates.length} candidates`}

+ {showResolveControls && ( +
+ +
+ )} @@ -254,14 +289,15 @@ function RecordCrossmatchDetails({ )} + {resolveError && ( +

+ {resolveError} +

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

Crossmatch Candidates

- {resolveError && ( -

- {resolveError} -

- )} {candidates.map((candidate) => ( resolveCandidate(candidate.pgc)} > - {resolvingPgc === candidate.pgc ? "Resolving…" : "Resolve"} + {resolving === candidate.pgc + ? "Saving…" + : getResource("crossmatch.action.resolve").Title} )}
From b696dd753cbb0106d775de3c449e15f96b5b0aa3 Mon Sep 17 00:00:00 2001 From: kraysent Date: Fri, 22 May 2026 18:58:57 +0100 Subject: [PATCH 2/5] add background to labels in aladin --- src/components/core/Aladin.tsx | 95 ++++++++++++++++++++++++++++++---- 1 file changed, 86 insertions(+), 9 deletions(-) diff --git a/src/components/core/Aladin.tsx b/src/components/core/Aladin.tsx index 95c4c75..731e468 100644 --- a/src/components/core/Aladin.tsx +++ b/src/components/core/Aladin.tsx @@ -1,6 +1,83 @@ import classNames from "classnames"; import { useEffect, useRef } from "react"; +const SOURCE_SIZE = 8; +const LABEL_FONT = "14px sans-serif"; +const LABEL_PADDING_X = 4; +const LABEL_PADDING_Y = 2; + +function drawCross( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + size: number, + color: string, +): void { + const half = size / 2; + const left = x - half; + const top = y - half; + + ctx.strokeStyle = color; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(left, top); + ctx.lineTo(left + size - 1, top + size - 1); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(left + size - 1, top); + ctx.lineTo(left, top + size - 1); + ctx.stroke(); +} + +function drawLabelWithBackground( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, +): void { + ctx.font = LABEL_FONT; + const metrics = ctx.measureText(text); + const ascent = metrics.actualBoundingBoxAscent || 11; + const descent = metrics.actualBoundingBoxDescent || 3; + const textWidth = metrics.width; + const textHeight = ascent + descent; + + const bgX = x; + const bgY = y - ascent - LABEL_PADDING_Y; + const bgWidth = textWidth + LABEL_PADDING_X * 2; + const bgHeight = textHeight + LABEL_PADDING_Y * 2; + + ctx.fillStyle = "rgba(255, 255, 255, 0.88)"; + ctx.fillRect(bgX, bgY, bgWidth, bgHeight); + + ctx.strokeStyle = "rgba(0, 0, 0, 0.25)"; + ctx.lineWidth = 1; + ctx.strokeRect(bgX + 0.5, bgY + 0.5, bgWidth - 1, bgHeight - 1); + + ctx.fillStyle = "#1a1a1a"; + ctx.fillText(text, x + LABEL_PADDING_X, y); +} + +type AladinCanvasSource = { + x: number; + y: number; + data?: { name?: string }; +}; + +function drawSourceWithLabel( + source: AladinCanvasSource, + ctx: CanvasRenderingContext2D, +): void { + drawCross(ctx, source.x, source.y, SOURCE_SIZE, "black"); + + const label = source.data?.name; + if (!label) { + return; + } + + drawLabelWithBackground(ctx, label, source.x + SOURCE_SIZE / 2, source.y); +} + interface AdditionalSource { ra: number; dec: number; @@ -45,12 +122,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", @@ -112,12 +187,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: ( From 7b4c52ba4f56720092d51a579be521410d2825cf Mon Sep 17 00:00:00 2001 From: kraysent Date: Fri, 22 May 2026 19:06:12 +0100 Subject: [PATCH 3/5] add selectors for crossmatch candidates --- src/App.tsx | 7 +- src/components/core/Aladin.tsx | 5 +- src/pages/RecordCrossmatchDetails.tsx | 355 +++++++++++++++++++------- 3 files changed, 270 insertions(+), 97 deletions(-) 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, @@ -152,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
; } diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index abf82e1..93ef04c 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, @@ -19,6 +18,7 @@ import { 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"; @@ -30,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 { @@ -98,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, @@ -107,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, @@ -129,8 +138,194 @@ function convertCandidatesToAdditionalSources( : candidateSources; } -interface RecordCrossmatchDetailsProps { - data: GetRecordCrossmatchResponse; +function ObjectSummary({ + catalogs, + schema, + name, +}: { + catalogs: Catalogs; + schema: BackendSchema; + name: ReactNode; +}): ReactElement { + const equatorial = catalogs?.coordinates?.equatorial; + const redshift = catalogs?.redshift; + + return ( +
+
Name
+
{name}
+ {equatorial && ( + <> +
RA
+
+ + + +
+
Dec
+
+ + + +
+ + )} + {redshift && ( + <> +
Redshift
+
+ + {redshift.z.toFixed(5)} + +
+ + )} +
+ ); +} + +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({ @@ -149,10 +344,15 @@ function OriginalData({ return ; } +interface RecordCrossmatchDetailsProps { + data: GetRecordCrossmatchResponse; +} + function RecordCrossmatchDetails({ data, }: RecordCrossmatchDetailsProps): ReactElement { - const [resolving, setResolving] = useState<"new" | number | null>(null); + const [selected, setSelected] = useState(null); + const [resolving, setResolving] = useState(null); const [resolveError, setResolveError] = useState(null); const { crossmatch, @@ -161,7 +361,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); @@ -174,6 +373,8 @@ 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, @@ -229,65 +430,71 @@ function RecordCrossmatchDetails({ } } + 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`} -

- {showResolveControls && ( -
- -
- )} +
+
+

+ {objectName} + + {triageBadgeLabel} + +

+

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

+

+ Table: {tableName} +

+

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

+
+
+

Object

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

@@ -295,42 +502,10 @@ function RecordCrossmatchDetails({

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

Crossmatch Candidates

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

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

- {showResolveControls && ( - - )} -
- -
- ))} -
+ {originalData && ( + + + )}
); From 27c3d217f068ee7a4427ba0ab9d3842a4ecb1f7a Mon Sep 17 00:00:00 2001 From: kraysent Date: Fri, 22 May 2026 19:07:50 +0100 Subject: [PATCH 4/5] fix width --- src/pages/RecordCrossmatchDetails.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/RecordCrossmatchDetails.tsx b/src/pages/RecordCrossmatchDetails.tsx index 93ef04c..dda0799 100644 --- a/src/pages/RecordCrossmatchDetails.tsx +++ b/src/pages/RecordCrossmatchDetails.tsx @@ -232,7 +232,7 @@ function ResolutionSelector({ if (!showResolveControls) { return ( -
+
{candidates.length > 0 && (

Candidates

@@ -262,7 +262,7 @@ function ResolutionSelector({ } return ( -
+

Resolution