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