From f6ca1bd114c5a5d769f1e7068cd1909894b1870f Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 22:46:26 +0100 Subject: [PATCH 1/8] add coordinate parsing with visual clues --- src/components/core/EditableTextField.tsx | 4 +- src/components/ui/Searchbar.tsx | 46 ++- src/lib/astronomy/parseCoordinateQuery.ts | 472 ++++++++++++++++++++++ src/pages/Home.tsx | 20 +- src/pages/SearchResults.tsx | 4 +- 5 files changed, 523 insertions(+), 23 deletions(-) create mode 100644 src/lib/astronomy/parseCoordinateQuery.ts diff --git a/src/components/core/EditableTextField.tsx b/src/components/core/EditableTextField.tsx index 22d8447..69c6b81 100644 --- a/src/components/core/EditableTextField.tsx +++ b/src/components/core/EditableTextField.tsx @@ -89,14 +89,14 @@ export function EditableTextField({ } return ( -
+
{(renderDisplay ?? defaultRender)(value)}
+
+ setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleSubmit(); + } + }} + /> + +
+ {coordinateHint ? ( +
+ {coordinateHint} +
+ ) : null}
); diff --git a/src/lib/astronomy/parseCoordinateQuery.ts b/src/lib/astronomy/parseCoordinateQuery.ts new file mode 100644 index 0000000..feea7d6 --- /dev/null +++ b/src/lib/astronomy/parseCoordinateQuery.ts @@ -0,0 +1,472 @@ +const ARCMINUTE_RADIUS_DEG = 1 / 60; + +export type CoordinateSystem = "j2000" | "b1950" | "galactic" | "supergalactic"; + +export type CoordinateQueryParams = { + ra?: number; + dec?: number; + eq_epoch?: string; + glon?: number; + glat?: number; + sgl?: number; + sgb?: number; + radius: number; +}; + +export type CoordinateQuery = { + system: CoordinateSystem; + lon: number; + lat: number; + toQueryParams: () => CoordinateQueryParams; +}; + +export type AxisDisplay = { + label: string; + display: string | null; +}; + +export type CoordinateInspect = + | { status: "none" } + | { + status: "partial" | "valid"; + system: CoordinateSystem; + systemLabel: string; + firstAxis: AxisDisplay; + secondAxis: AxisDisplay; + query: CoordinateQuery | null; + }; + +type Prefix = "J" | "B" | "G" | "S"; + +function systemFromPrefix(prefix: Prefix | null): CoordinateSystem { + switch (prefix) { + case "B": + return "b1950"; + case "G": + return "galactic"; + case "S": + return "supergalactic"; + default: + return "j2000"; + } +} + +function systemLabel(system: CoordinateSystem): string { + switch (system) { + case "b1950": + return "B1950"; + case "galactic": + return "Galactic"; + case "supergalactic": + return "Supergalactic"; + default: + return "J2000"; + } +} + +function axisLabels(system: CoordinateSystem): { + first: string; + second: string; +} { + switch (system) { + case "galactic": + return { first: "l", second: "b" }; + case "supergalactic": + return { first: "SGL", second: "SGB" }; + default: + return { first: "RA", second: "Dec" }; + } +} + +function isEquatorial(system: CoordinateSystem): boolean { + return system === "j2000" || system === "b1950"; +} + +function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +function integerDigitCount(token: string): number { + const dot = token.indexOf("."); + return (dot === -1 ? token : token.slice(0, dot)).length; +} + +function isSexagesimalToken(token: string): boolean { + return integerDigitCount(token) >= 4; +} + +function looksCoordinateShaped(body: string): boolean { + return /^(\d+\.?\d*)?([+-]\d*\.?\d*)?$/.test(body); +} + +function parseDecimalDegrees(token: string): number | null { + if (!/^\d+(\.\d+)?$/.test(token)) { + return null; + } + const value = Number(token); + return Number.isFinite(value) ? value : null; +} + +function packedToComponents( + token: string, + degreeDigits: number, +): { degrees: number; minutes: number; seconds: number } | null { + if (!/^\d+(\.\d+)?$/.test(token)) { + return null; + } + + const [whole, fraction = ""] = token.split("."); + if (whole.length < 1) { + return null; + } + + if (whole.length < degreeDigits) { + const degrees = Number(`${whole}${fraction ? `.${fraction}` : ""}`); + return Number.isFinite(degrees) + ? { degrees, minutes: 0, seconds: 0 } + : null; + } + + const degrees = Number(whole.slice(0, degreeDigits)); + const rest = whole.slice(degreeDigits); + + if (rest.length === 0) { + const deg = Number(`${degrees}${fraction ? `.${fraction}` : ""}`); + return Number.isFinite(deg) + ? { degrees: deg, minutes: 0, seconds: 0 } + : null; + } + + if (rest.length <= 2) { + const minutes = Number(`${rest}${fraction ? `.${fraction}` : ""}`); + if ( + !Number.isFinite(degrees) || + !Number.isFinite(minutes) || + minutes >= 60 + ) { + return null; + } + return { degrees, minutes, seconds: 0 }; + } + + const minutes = Number(rest.slice(0, 2)); + const seconds = Number( + `${rest.slice(2) || "0"}${fraction ? `.${fraction}` : ""}`, + ); + if ( + !Number.isFinite(degrees) || + !Number.isFinite(minutes) || + !Number.isFinite(seconds) || + minutes >= 60 || + seconds >= 60 + ) { + return null; + } + return { degrees, minutes, seconds }; +} + +function componentsToDegrees( + degrees: number, + minutes: number, + seconds: number, +): number { + return degrees + minutes / 60 + seconds / 3600; +} + +function packedHoursToDegrees(token: string): number | null { + const components = packedToComponents(token, 2); + if (!components || components.degrees >= 24) { + return null; + } + return ( + componentsToDegrees( + components.degrees, + components.minutes, + components.seconds, + ) * 15 + ); +} + +function packedDegreesToDegrees( + token: string, + degreeDigits: number, +): number | null { + const components = packedToComponents(token, degreeDigits); + if (!components) { + return null; + } + const maxDegrees = degreeDigits === 3 ? 360 : 90; + if (components.degrees > maxDegrees) { + return null; + } + return componentsToDegrees( + components.degrees, + components.minutes, + components.seconds, + ); +} + +function formatRaDisplay(degrees: number): string { + const totalSeconds = (((degrees % 360) + 360) % 360) * 240; + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + return `${pad2(h)}h ${pad2(m)}m ${s.toFixed(2).padStart(5, "0")}s`; +} + +function formatDecDisplay(degrees: number): string { + const sign = degrees < 0 ? "-" : "+"; + const abs = Math.abs(degrees); + const d = Math.floor(abs); + const minutesFloat = (abs - d) * 60; + const m = Math.floor(minutesFloat); + const s = (minutesFloat - m) * 60; + return `${sign}${d}° ${pad2(m)}′ ${s.toFixed(1).padStart(4, "0")}″`; +} + +function formatLonLatDisplay(degrees: number): string { + return `${degrees.toFixed(4)}°`; +} + +function formatFirstAxis(system: CoordinateSystem, degrees: number): string { + return isEquatorial(system) + ? formatRaDisplay(degrees) + : formatLonLatDisplay(degrees); +} + +function formatSecondAxis(system: CoordinateSystem, degrees: number): string { + return isEquatorial(system) + ? formatDecDisplay(degrees) + : formatLonLatDisplay(degrees); +} + +function parseFirstAxis( + system: CoordinateSystem, + token: string, +): number | null { + if (!token) { + return null; + } + + if (isSexagesimalToken(token)) { + return isEquatorial(system) + ? packedHoursToDegrees(token) + : packedDegreesToDegrees(token, 3); + } + + const value = parseDecimalDegrees(token); + if (value === null || value > 360) { + return null; + } + return value; +} + +function parseSecondAxis( + token: string, + sign: "+" | "-", + firstWasSexagesimal: boolean, +): number | null { + if (!token) { + return null; + } + + const abs = firstWasSexagesimal + ? packedDegreesToDegrees(token, 2) + : parseDecimalDegrees(token); + + if (abs === null) { + return null; + } + + const value = sign === "-" ? -abs : abs; + if (Math.abs(value) > 90) { + return null; + } + return value; +} + +function buildQuery( + system: CoordinateSystem, + lon: number, + lat: number, +): CoordinateQuery | null { + if (lon < 0 || lon > 360 || lat < -90 || lat > 90) { + return null; + } + + return { + system, + lon, + lat, + toQueryParams(): CoordinateQueryParams { + switch (system) { + case "b1950": + return { + ra: lon, + dec: lat, + eq_epoch: "B1950", + radius: ARCMINUTE_RADIUS_DEG, + }; + case "galactic": + return { glon: lon, glat: lat, radius: ARCMINUTE_RADIUS_DEG }; + case "supergalactic": + return { sgl: lon, sgb: lat, radius: ARCMINUTE_RADIUS_DEG }; + default: + return { + ra: lon, + dec: lat, + eq_epoch: "J2000", + radius: ARCMINUTE_RADIUS_DEG, + }; + } + }, + }; +} + +type ParsedShape = { + prefix: Prefix | null; + firstToken: string; + sign: "+" | "-" | null; + secondToken: string; + hasSeparator: boolean; +}; + +function splitInput(trimmed: string): ParsedShape | null { + if (!trimmed) { + return null; + } + + const prefixMatch = /^([JBGS])(.*)$/i.exec(trimmed); + let prefix: Prefix | null = null; + let body = trimmed; + + if (prefixMatch) { + prefix = prefixMatch[1].toUpperCase() as Prefix; + body = prefixMatch[2]; + } else if (!/^\d/.test(trimmed)) { + return null; + } + + if (body.length > 0 && !looksCoordinateShaped(body)) { + return null; + } + + if (body.length === 0) { + if (prefix === null) { + return null; + } + return { + prefix, + firstToken: "", + sign: null, + secondToken: "", + hasSeparator: false, + }; + } + + const sepIndex = body.search(/[+-]/); + if (sepIndex === -1) { + return { + prefix, + firstToken: body, + sign: null, + secondToken: "", + hasSeparator: false, + }; + } + + return { + prefix, + firstToken: body.slice(0, sepIndex), + sign: body[sepIndex] as "+" | "-", + secondToken: body.slice(sepIndex + 1), + hasSeparator: true, + }; +} + +export function inspectCoordinateQuery(input: string): CoordinateInspect { + const trimmed = input.trim(); + const shape = splitInput(trimmed); + if (!shape) { + return { status: "none" }; + } + + const system = systemFromPrefix(shape.prefix); + const labels = axisLabels(system); + const firstLon = + shape.firstToken.length > 0 + ? parseFirstAxis(system, shape.firstToken) + : null; + const firstWasSexagesimal = + shape.firstToken.length > 0 && isSexagesimalToken(shape.firstToken); + + let secondLat: number | null = null; + if (shape.hasSeparator && shape.sign && shape.secondToken.length > 0) { + secondLat = parseSecondAxis( + shape.secondToken, + shape.sign, + firstWasSexagesimal, + ); + } + + const query = + firstLon !== null && secondLat !== null + ? buildQuery(system, firstLon, secondLat) + : null; + + if (query) { + return { + status: "valid", + system, + systemLabel: systemLabel(system), + firstAxis: { + label: labels.first, + display: formatFirstAxis(system, query.lon), + }, + secondAxis: { + label: labels.second, + display: formatSecondAxis(system, query.lat), + }, + query, + }; + } + + return { + status: "partial", + system, + systemLabel: systemLabel(system), + firstAxis: { + label: labels.first, + display: firstLon !== null ? formatFirstAxis(system, firstLon) : null, + }, + secondAxis: { + label: labels.second, + display: secondLat !== null ? formatSecondAxis(system, secondLat) : null, + }, + query: null, + }; +} + +export function parseCoordinateQuery(input: string): CoordinateQuery | null { + const inspected = inspectCoordinateQuery(input); + if (inspected.status !== "valid" || !inspected.query) { + return null; + } + return inspected.query; +} + +export function formatCoordinateInspectHint( + inspected: CoordinateInspect, +): string | null { + if (inspected.status === "none") { + return null; + } + + const first = inspected.firstAxis.display ?? "—"; + const second = inspected.secondAxis.display ?? "—"; + const base = `${inspected.systemLabel} · ${inspected.firstAxis.label} ${first} · ${inspected.secondAxis.label} ${second}`; + if (inspected.status === "valid") { + return `${base} · 1′ search`; + } + return base; +} diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index dba8a85..fde8a07 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -9,12 +9,24 @@ const homePageHint: ReactElement = ( By name (string match): IC 144
  • - By coordinates (hms+dms):{" "} - 12h32m22s+15d22m45s + By coordinates (J2000 packed):{" "} + J001122.33+443322.1
  • - By coordinates (J2000):{" "} - J001122.33+443322.1 + By coordinates (decimal degrees):{" "} + 187.7059+12.3911 +
  • +
  • + By coordinates (B1950):{" "} + B123456.7-012345.6 +
  • +
  • + By coordinates (galactic):{" "} + G187.7059+12.3911 +
  • +
  • + By coordinates (supergalactic):{" "} + S123.45-01.23
  • diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index f234495..5e5552f 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -15,6 +15,7 @@ import { Link } from "../components/core/Link"; import { Declination, RightAscension } from "../components/core/Astronomy"; import { Pagination } from "../components/ui/Pagination"; import { backendClient } from "../clients/config"; +import { parseCoordinateQuery } from "../lib/astronomy/parseCoordinateQuery"; function searchHandler(navigate: NavigateFunction) { return function f(query: string) { @@ -125,10 +126,11 @@ async function fetcher( throw new Error("Empty query"); } + const coordinateQuery = parseCoordinateQuery(query); const response = await querySimple({ client: backendClient, query: { - name: query, + ...(coordinateQuery ? coordinateQuery.toQueryParams() : { name: query }), page: page, page_size: pageSize, }, From 50fd6ead6e3de701239e1f9f43d65f2ebf32d63b Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 22:48:09 +0100 Subject: [PATCH 2/8] real examples --- src/pages/Home.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index fde8a07..2aa99d8 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -10,7 +10,7 @@ const homePageHint: ReactElement = (
  • By coordinates (J2000 packed):{" "} - J001122.33+443322.1 + J123049.42+122328.0
  • By coordinates (decimal degrees):{" "} @@ -18,15 +18,15 @@ const homePageHint: ReactElement = (
  • By coordinates (B1950):{" "} - B123456.7-012345.6 + B123049.4+122328
  • By coordinates (galactic):{" "} - G187.7059+12.3911 + G283.777+74.491
  • By coordinates (supergalactic):{" "} - S123.45-01.23 + S102.89-2.35
  • From 0c10717279cbc57cd10b793dea7a8481836bcee0 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 22:52:15 +0100 Subject: [PATCH 3/8] suggestion bar --- src/components/ui/Searchbar.tsx | 62 +++++++++++++++-------- src/lib/astronomy/parseCoordinateQuery.ts | 6 +-- 2 files changed, 41 insertions(+), 27 deletions(-) diff --git a/src/components/ui/Searchbar.tsx b/src/components/ui/Searchbar.tsx index b7620b0..7a690a7 100644 --- a/src/components/ui/Searchbar.tsx +++ b/src/components/ui/Searchbar.tsx @@ -20,6 +20,21 @@ function searchHandler(navigate: NavigateFunction) { }; } +function searchSuggestion(query: string): string | null { + const trimmed = query.trim(); + if (!trimmed) { + return null; + } + + const coordinateHint = formatCoordinateInspectHint( + inspectCoordinateQuery(trimmed), + ); + if (coordinateHint) { + return `Will search around coordinates: ${coordinateHint}`; + } + return `Will search name: ${trimmed}`; +} + export function SearchBar({ initialValue = "", logoSize = "small", @@ -27,11 +42,10 @@ export function SearchBar({ className, }: SearchBarProps): ReactElement { const [searchQuery, setSearchQuery] = useState(initialValue); + const [focused, setFocused] = useState(false); const navigate = useNavigate(); const onSearchHandler = onSearch ?? searchHandler(navigate); - const coordinateHint = formatCoordinateInspectHint( - inspectCoordinateQuery(searchQuery), - ); + const suggestion = focused ? searchSuggestion(searchQuery) : null; function handleSubmit() { if (searchQuery.trim()) { @@ -63,28 +77,32 @@ export function SearchBar({ "max-w-4xl mx-auto": logoSize === "large", })} > -
    - setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleSubmit(); - } - }} - /> -
    - {coordinateHint ? ( -
    - {coordinateHint} -
    - ) : null} ); diff --git a/src/lib/astronomy/parseCoordinateQuery.ts b/src/lib/astronomy/parseCoordinateQuery.ts index feea7d6..a4c5bb4 100644 --- a/src/lib/astronomy/parseCoordinateQuery.ts +++ b/src/lib/astronomy/parseCoordinateQuery.ts @@ -464,9 +464,5 @@ export function formatCoordinateInspectHint( const first = inspected.firstAxis.display ?? "—"; const second = inspected.secondAxis.display ?? "—"; - const base = `${inspected.systemLabel} · ${inspected.firstAxis.label} ${first} · ${inspected.secondAxis.label} ${second}`; - if (inspected.status === "valid") { - return `${base} · 1′ search`; - } - return base; + return `${inspected.systemLabel} · ${inspected.firstAxis.label} ${first} · ${inspected.secondAxis.label} ${second}`; } From 30b89aff743241e9d188de5d47030ba4eb1e4095 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 22:54:15 +0100 Subject: [PATCH 4/8] add code review skill --- .agents/skills/code-review/SKILL.md | 101 ++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 .agents/skills/code-review/SKILL.md diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 0000000..afee351 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,101 @@ +--- +name: code-review +description: Only use this skill when explicitly asked to conduct a code review for a piece of code or a pull request. +--- + +Below is a set of different specific code review rules that you should check when asked for a review. Only check these rules and nothing else. Open-ended code reviews are not covered by this skill. + +When conducting a code review, read the diff and check it against each rule below. Read relevant pieces of code if needed for understanding the context around the diff. Each rule consists of the slug of the rule and textual description of what specifically should be checked by the rule. + +Your response should be a structured response in JSON form that you write in `.vscode/code-review.json` file in this repository. + +JSON should have strictly the following form: + +```json +[ + { + "name": "typos", + "file": "path/from/the/root/of/the/repo/file.py", + "line_from": 10, + "line_to": 20, + "description": "Word 'asembly' is written with a typo, you likely meant 'assembly'" + }, + { + "name": "incorrect-comments", + "file": "path/from/the/root/of/the/repo/another.py", + "line_from": 15, + "line_to": 21, + "description": "The comment says that this algorithm works in O(N) but most common path according to numpy documentation is O(N^2)" + } +] +``` + +You MUST adhere to this form because it will be later used by automation to create a user-friendly UI. + +If the rule asks for a citation, put it in the `description` field. + +Below are rules you should check when reviewing the code. + +### duplicate-code + +The code written is an obvious copy-pasta of the other part of the code in the diff or in other parts of the codebase. This rule only includes code that is an actual copy and paste of another part of the codebase or is very trivially extractable into functions. + +### incorrect-comments + +The comment used for a function, variable, or expression contradicts the content of the token it describes. This also includes docstrings or any other kind of documentation directly inside the code. Token names are not included in this rule. + +### misleading-name + +The name of a function, method, variable, parameter, or class contradicts what it actually does. Examples of contradictions: a get*\* or fetch*_ function that mutates state, an is\__/has\_\* name that does not return a boolean, a singular name bound to a collection, a boolean flag whose name implies the opposite polarity of the behavior it controls, or a verb that names a different operation than the one performed. Only flag when the mismatch is visible in the body of the token in the diff or in the code the diff calls. Do not flag names that are merely vague, short, or abbreviated. + +### typos + +A comment, token, or any other part of the code contains a typo. + +### obvious-comment + +A new or changed comment only restates what the next line or block already makes obvious, and adds no non-obvious rationale, constraint, or decision. Do not flag comments that explain why, trade-offs, invariants, or external constraints. + +### stale-reference + +The diff renames, moves, or deletes a symbol, module, file, CLI flag, config key, environment variable, or API field, but references to the old name survive elsewhere in the repository, in places the type checker does not see: comments, docstrings, error and log message strings, README or docs pages, YAML or TOML config, makefile targets, or test names. Only flag when you have located the surviving old reference and can cite its file and line. + +### caller-impact + +The diff changes the observable contract of a function used outside the diff, without updating those callers. Contract changes that count: the meaning or unit of a return value, None versus empty collection, ordering guarantees, which exception type is raised, whether an input argument is mutated, or the value of a default parameter. Only flag when at least one caller outside the diff is identified by file and line and would behave differently under the new contract. Do not flag changes the type checker would already reject. + +### unit-mismatch + +A quantity is produced in one unit, scale, or coordinate convention and consumed as if it were in another: degrees versus radians, seconds versus milliseconds, bytes versus kilobytes, zero-based versus one-based indices, or a value converted to a target unit in one branch but not another. Only flag when the source unit is determinable from the code, a column definition, a constant name, or a unit-bearing type in the diff or its immediate context. + +### swallowed-error + +An except clause catches an exception and then neither re-raises it, nor logs it, nor forwards it to the caller or user through the project's reporting mechanism, so the failure becomes invisible. Only flag when the handler body discards the exception entirely (pass, bare return, or assigning a fallback with no record of the failure). Do not flag handlers at documented process boundaries that log or report the exception, and do not flag handlers whose enclosing function's contract is explicitly to tolerate the failure. + +### inconsistent-duplicate + +The diff modifies one instance of code that is duplicated elsewhere in the repository while leaving the other instances unchanged, so the copies now disagree. Only flag when you have located the near-identical counterpart and can cite its file and line, and when the divergence affects behavior rather than only formatting. + +### generic-exception + +The code raises a built-in exception such as Exception, RuntimeError, or ValueError in a situation for which the project already defines a more specific exception class, or raises an exception belonging to a different layer than the one it is raised from. Only flag when the more appropriate exception class exists in the repository and is used for comparable situations elsewhere, cited by file and line. + +### layer-violation + +The diff introduces an import that inverts the dependency direction already established between two packages: module A imports package B, while B already imports A's package. Only flag when you can cite an existing import establishing the opposite direction. Also flag imports of code the project treats as generated or vendored being edited or wrapped in a way that a regeneration step would overwrite. + +### test-name-mismatch + +The name of a test describes a behavior, input, or outcome different from what the test body actually sets up and asserts. Only flag when the discrepancy is in the substance of the test, not in wording or abbreviation. + +### misleading-user-text + +Text that reaches an end user contradicts the code that produces or consumes it: a form field title or description, CLI help text, a task description, or an error message. + +### unmanaged-resource + +A resource that requires deterministic release — database connection or cursor, transaction, file, thread, executor, temporary directory, network client — is acquired in the diff without a with block or an equivalent guaranteed-cleanup path, so it leaks when an exception is raised. Only flag when the project provides a context manager or cleanup helper for that resource, cited by file and line, or when the same resource is acquired with a with block elsewhere in the codebase. + +### call-in-loop + +A database query, HTTP request, or other heavy I/O call is issued once per item inside a loop where the project already provides a batched or bulk equivalent. Only flag when the batched helper exists, cited by file and line, and the loop bound is data-dependent rather than a small fixed number. From 4657a1b7b3d8c842f1caa8b31f013354f5cc52a5 Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 23:02:51 +0100 Subject: [PATCH 5/8] better suggestions --- src/components/core/Astronomy.tsx | 33 ++++------------------ src/components/ui/Searchbar.tsx | 34 +++++++++++++++++------ src/lib/astronomy/parseCoordinateQuery.ts | 24 ++++++---------- src/lib/astronomy/sexagesimal.ts | 31 +++++++++++++++++++++ 4 files changed, 70 insertions(+), 52 deletions(-) create mode 100644 src/lib/astronomy/sexagesimal.ts diff --git a/src/components/core/Astronomy.tsx b/src/components/core/Astronomy.tsx index 94bb3c8..d45134f 100644 --- a/src/components/core/Astronomy.tsx +++ b/src/components/core/Astronomy.tsx @@ -1,4 +1,9 @@ import React, { ReactElement, ReactNode } from "react"; +import { + decomposeDec, + decomposeRa, + pad2, +} from "../../lib/astronomy/sexagesimal"; interface QuantityProps { value: string | number; @@ -53,10 +58,6 @@ interface AstronomicalCoordinateProps { className?: string; } -function pad2(value: number): string { - return String(value).padStart(2, "0"); -} - function formatSexagesimalSeconds(seconds: number, decimals: number): string { const fixed = seconds.toFixed(decimals); const [integerPart, fractionalPart] = fixed.split("."); @@ -65,30 +66,6 @@ function formatSexagesimalSeconds(seconds: number, decimals: number): string { : `${pad2(Number(integerPart))}.${fractionalPart}`; } -function decomposeRa(degrees: number): { h: number; m: number; s: number } { - const totalSeconds = degrees * 240; - return { - h: Math.floor(totalSeconds / 3600), - m: Math.floor((totalSeconds % 3600) / 60), - s: totalSeconds % 60, - }; -} - -function decomposeDec(degrees: number): { - sign: string; - d: number; - m: number; - s: number; -} { - const sign = degrees < 0 ? "-" : "+"; - const absDec = Math.abs(degrees); - const d = Math.floor(absDec); - const minutesFloat = (absDec - d) * 60; - const m = Math.floor(minutesFloat); - const s = (minutesFloat - m) * 60; - return { sign, d, m, s }; -} - export type EquatorialCopyFormat = | "sexagesimal-units" | "sexagesimal-colon" diff --git a/src/components/ui/Searchbar.tsx b/src/components/ui/Searchbar.tsx index 7a690a7..4653b88 100644 --- a/src/components/ui/Searchbar.tsx +++ b/src/components/ui/Searchbar.tsx @@ -20,19 +20,34 @@ function searchHandler(navigate: NavigateFunction) { }; } -function searchSuggestion(query: string): string | null { +type SearchSuggestion = { + primary: string; + secondary?: string; +}; + +function searchSuggestion(query: string): SearchSuggestion | null { const trimmed = query.trim(); if (!trimmed) { return null; } - const coordinateHint = formatCoordinateInspectHint( - inspectCoordinateQuery(trimmed), - ); - if (coordinateHint) { - return `Will search around coordinates: ${coordinateHint}`; + const inspected = inspectCoordinateQuery(trimmed); + const coordinateHint = formatCoordinateInspectHint(inspected); + + if (inspected.status === "valid" && coordinateHint) { + return { + primary: `Will search around coordinates: ${coordinateHint}`, + }; } - return `Will search name: ${trimmed}`; + + if (inspected.status === "partial" && coordinateHint) { + return { + primary: `Will search name: ${trimmed}`, + secondary: `If typed fully, will search around coordinates: ${coordinateHint}`, + }; + } + + return { primary: `Will search name: ${trimmed}` }; } export function SearchBar({ @@ -95,7 +110,10 @@ export function SearchBar({ /> {suggestion ? (
    - {suggestion} +
    {suggestion.primary}
    + {suggestion.secondary ? ( +
    {suggestion.secondary}
    + ) : null}
    ) : null} diff --git a/src/lib/astronomy/parseCoordinateQuery.ts b/src/lib/astronomy/parseCoordinateQuery.ts index a4c5bb4..2e40882 100644 --- a/src/lib/astronomy/parseCoordinateQuery.ts +++ b/src/lib/astronomy/parseCoordinateQuery.ts @@ -1,4 +1,7 @@ -const ARCMINUTE_RADIUS_DEG = 1 / 60; +import { decomposeDec, decomposeRa, pad2 } from "./sexagesimal"; + +export const COORDINATE_SEARCH_RADIUS_ARCMIN = 1; +const ARCMINUTE_RADIUS_DEG = COORDINATE_SEARCH_RADIUS_ARCMIN / 60; export type CoordinateSystem = "j2000" | "b1950" | "galactic" | "supergalactic"; @@ -82,10 +85,6 @@ function isEquatorial(system: CoordinateSystem): boolean { return system === "j2000" || system === "b1950"; } -function pad2(value: number): string { - return String(value).padStart(2, "0"); -} - function integerDigitCount(token: string): number { const dot = token.indexOf("."); return (dot === -1 ? token : token.slice(0, dot)).length; @@ -207,20 +206,13 @@ function packedDegreesToDegrees( } function formatRaDisplay(degrees: number): string { - const totalSeconds = (((degrees % 360) + 360) % 360) * 240; - const h = Math.floor(totalSeconds / 3600); - const m = Math.floor((totalSeconds % 3600) / 60); - const s = totalSeconds % 60; + const normalized = ((degrees % 360) + 360) % 360; + const { h, m, s } = decomposeRa(normalized); return `${pad2(h)}h ${pad2(m)}m ${s.toFixed(2).padStart(5, "0")}s`; } function formatDecDisplay(degrees: number): string { - const sign = degrees < 0 ? "-" : "+"; - const abs = Math.abs(degrees); - const d = Math.floor(abs); - const minutesFloat = (abs - d) * 60; - const m = Math.floor(minutesFloat); - const s = (minutesFloat - m) * 60; + const { sign, d, m, s } = decomposeDec(degrees); return `${sign}${d}° ${pad2(m)}′ ${s.toFixed(1).padStart(4, "0")}″`; } @@ -464,5 +456,5 @@ export function formatCoordinateInspectHint( const first = inspected.firstAxis.display ?? "—"; const second = inspected.secondAxis.display ?? "—"; - return `${inspected.systemLabel} · ${inspected.firstAxis.label} ${first} · ${inspected.secondAxis.label} ${second}`; + return `${inspected.systemLabel} · ${inspected.firstAxis.label} ${first} · ${inspected.secondAxis.label} ${second} · radius ${COORDINATE_SEARCH_RADIUS_ARCMIN}′`; } diff --git a/src/lib/astronomy/sexagesimal.ts b/src/lib/astronomy/sexagesimal.ts new file mode 100644 index 0000000..3318275 --- /dev/null +++ b/src/lib/astronomy/sexagesimal.ts @@ -0,0 +1,31 @@ +export function pad2(value: number): string { + return String(value).padStart(2, "0"); +} + +export function decomposeRa(degrees: number): { + h: number; + m: number; + s: number; +} { + const totalSeconds = degrees * 240; + return { + h: Math.floor(totalSeconds / 3600), + m: Math.floor((totalSeconds % 3600) / 60), + s: totalSeconds % 60, + }; +} + +export function decomposeDec(degrees: number): { + sign: string; + d: number; + m: number; + s: number; +} { + const sign = degrees < 0 ? "-" : "+"; + const absDec = Math.abs(degrees); + const d = Math.floor(absDec); + const minutesFloat = (absDec - d) * 60; + const m = Math.floor(minutesFloat); + const s = (minutesFloat - m) * 60; + return { sign, d, m, s }; +} From f8c0f47be62691d72222e5f22ffc49a0c1bfa31d Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 23:05:59 +0100 Subject: [PATCH 6/8] add aladin embed --- src/pages/SearchResults.tsx | 75 +++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 3 deletions(-) diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 5e5552f..9625116 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -10,13 +10,17 @@ import { Loading } from "../components/core/Loading"; import { ErrorPage, ErrorPageHomeButton } from "../components/ui/ErrorPage"; import { useDataFetching } from "../hooks/useDataFetching"; import { querySimple } from "../clients/backend/sdk.gen"; -import { QuerySimpleResponse } from "../clients/backend/types.gen"; +import { PgcObject, QuerySimpleResponse } from "../clients/backend/types.gen"; import { Link } from "../components/core/Link"; import { Declination, RightAscension } from "../components/core/Astronomy"; +import { AladinViewer } from "../components/core/Aladin"; import { Pagination } from "../components/ui/Pagination"; import { backendClient } from "../clients/config"; import { parseCoordinateQuery } from "../lib/astronomy/parseCoordinateQuery"; +const MIN_ALADIN_FOV_DEG = 0.05; +const ALADIN_FOV_PADDING = 1.4; + function searchHandler(navigate: NavigateFunction) { return function f(query: string) { navigate(`/query?q=${encodeURIComponent(query)}`); @@ -34,6 +38,59 @@ function pageChangeHandler( ); } +type SkySource = { + ra: number; + dec: number; + label: string; +}; + +function objectsToSkySources(objects: PgcObject[]): SkySource[] { + return objects.flatMap((object) => { + const equatorial = object.catalogs.coordinates?.equatorial; + if (equatorial?.ra === undefined || equatorial?.dec === undefined) { + return []; + } + + return [ + { + ra: equatorial.ra, + dec: equatorial.dec, + label: object.catalogs.designation?.name || `PGC ${object.pgc}`, + }, + ]; + }); +} + +function skyViewForSources(sources: SkySource[]): { + ra: number; + dec: number; + fov: number; +} | null { + if (sources.length === 0) { + return null; + } + + const ra = + sources.reduce((sum, source) => sum + source.ra, 0) / sources.length; + const dec = + sources.reduce((sum, source) => sum + source.dec, 0) / sources.length; + + if (sources.length === 1) { + return { ra, dec, fov: MIN_ALADIN_FOV_DEG }; + } + + const raSpan = + Math.max(...sources.map((source) => source.ra)) - + Math.min(...sources.map((source) => source.ra)); + const decSpan = + Math.max(...sources.map((source) => source.dec)) - + Math.min(...sources.map((source) => source.dec)); + const fov = + Math.max(raSpan, decSpan, MIN_ALADIN_FOV_DEG) * ALADIN_FOV_PADDING; + + return { ra, dec, fov }; +} + interface SearchResultsProps { results: QuerySimpleResponse; query: string; @@ -84,8 +141,20 @@ function SearchResults({ } if (results.objects.length > 0) { + const skySources = objectsToSkySources(results.objects); + const skyView = skyViewForSources(skySources); + return ( - <> +
    + {skyView ? ( + + ) : null} ({ @@ -102,7 +171,7 @@ function SearchResults({ records={results.objects} handlePageChange={handlePageChange} /> - +
    ); } From dfae225b17826dc8d6b8410dcd4a21b4b52700aa Mon Sep 17 00:00:00 2001 From: kraysent Date: Sun, 2 Aug 2026 23:14:32 +0100 Subject: [PATCH 7/8] improve navigation --- src/components/core/Aladin.tsx | 46 ++++++++++++++++++++++++++++------ src/pages/SearchResults.tsx | 5 ++++ 2 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/components/core/Aladin.tsx b/src/components/core/Aladin.tsx index d4ef2ed..afddd90 100644 --- a/src/components/core/Aladin.tsx +++ b/src/components/core/Aladin.tsx @@ -87,10 +87,17 @@ function drawLabelWithBackground( ctx.fillText(text, x + LABEL_PADDING_X, y); } +type AladinSourceData = { + name?: string; + id?: string | number; + popupTitle?: string; + popupDesc?: string; +}; + type AladinCanvasSource = { x: number; y: number; - data?: { name?: string }; + data?: AladinSourceData; }; function drawSourceWithLabel( @@ -111,6 +118,7 @@ interface AdditionalSource { ra: number; dec: number; label: string; + id?: string | number; description?: string; } @@ -121,6 +129,7 @@ interface AladinViewerProps { survey?: string; className?: string; additionalSources?: AdditionalSource[]; + onSourceClick?: (id: string | number) => void; } export function AladinViewer({ @@ -130,11 +139,17 @@ export function AladinViewer({ survey = DEFAULT_ALADIN_SURVEY, className = "w-full h-96", additionalSources, + onSourceClick, }: AladinViewerProps) { const aladinDivRef = useRef(null); + const onSourceClickRef = useRef(onSourceClick); const [selectedSurvey, setSelectedSurvey] = useState(survey); const additionalSourcesKey = JSON.stringify(additionalSources ?? []); + useEffect(() => { + onSourceClickRef.current = onSourceClick; + }, [onSourceClick]); + useEffect(() => { setSelectedSurvey(survey); }, [survey]); @@ -157,6 +172,14 @@ export function AladinViewer({ aladin.gotoRaDec(ra, dec); + aladin.on("objectClicked", (object) => { + const id = object?.data?.id; + if (id === undefined) { + return; + } + onSourceClickRef.current?.(id); + }); + if (additionalSources && additionalSources.length > 0) { const nameCatalog = window.A.catalog({ shape: drawSourceWithLabel, @@ -172,18 +195,20 @@ export function AladinViewer({ aladin.addCatalog(descrCatalog); additionalSources.forEach((source) => { + const data: AladinSourceData = { + name: source.label, + id: source.id, + }; if (source.description) { descrCatalog.addSources([ window.A.marker(source.ra, source.dec, { - name: source.label, + ...data, popupTitle: source.label, popupDesc: source.description, }), ]); } - nameCatalog.addSources( - window.A.source(source.ra, source.dec, { name: source.label }), - ); + nameCatalog.addSources(window.A.source(source.ra, source.dec, data)); }); } } catch (error) { @@ -217,7 +242,8 @@ interface AladinCatalog { interface AladinSource { ra: number; dec: number; - properties?: { name?: string; popupTitle?: string; popupDesc?: string }; + data?: AladinSourceData; + properties?: AladinSourceData; } declare global { @@ -238,6 +264,10 @@ declare global { gotoObject: (target: string) => void; gotoRaDec: (ra: number, dec: number) => void; addCatalog: (catalog: AladinCatalog) => void; + on: ( + event: "objectClicked", + callback: (object: AladinSource | null) => void, + ) => void; }; catalog: (options?: { displayLabel?: boolean; @@ -253,12 +283,12 @@ declare global { source: ( ra: number, dec: number, - properties?: { name?: string }, + properties?: AladinSourceData, ) => AladinSource; marker: ( ra: number, dec: number, - properties?: { name?: string; popupTitle?: string; popupDesc?: string }, + properties?: AladinSourceData, ) => AladinSource; }; } diff --git a/src/pages/SearchResults.tsx b/src/pages/SearchResults.tsx index 9625116..e3efd95 100644 --- a/src/pages/SearchResults.tsx +++ b/src/pages/SearchResults.tsx @@ -42,6 +42,7 @@ type SkySource = { ra: number; dec: number; label: string; + id: number; }; function objectsToSkySources(objects: PgcObject[]): SkySource[] { @@ -56,6 +57,7 @@ function objectsToSkySources(objects: PgcObject[]): SkySource[] { ra: equatorial.ra, dec: equatorial.dec, label: object.catalogs.designation?.name || `PGC ${object.pgc}`, + id: object.pgc, }, ]; }); @@ -153,6 +155,9 @@ function SearchResults({ fov={skyView.fov} className="w-full h-72" additionalSources={skySources} + onSourceClick={(id) => + window.open(`/object/${id}`, "_blank", "noopener,noreferrer") + } /> ) : null} Date: Sun, 2 Aug 2026 23:22:40 +0100 Subject: [PATCH 8/8] code review things --- src/lib/tap.ts | 17 +++++++++++------ src/pages/DataCatalog.tsx | 18 +++--------------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/lib/tap.ts b/src/lib/tap.ts index c5f990b..851f4a7 100644 --- a/src/lib/tap.ts +++ b/src/lib/tap.ts @@ -4,7 +4,12 @@ import type { ValidationError, } from "../clients/backend/types.gen"; import { backendClient } from "../clients/config"; -import type { CellPrimitive, Column } from "../components/ui/CommonTable"; + +export type TapCellValue = string | number; + +export interface TapTableColumn { + name: string; +} export const DEFAULT_SQL_EXAMPLE = "SELECT * FROM layer2.designations WHERE pgc = 67872"; @@ -31,7 +36,7 @@ export async function executeSqlQuery(sql: string): Promise { return response.data.data; } -export function cellValue(value: unknown): CellPrimitive { +export function cellValue(value: unknown): TapCellValue { if (value === null || value === undefined) { return "—"; } @@ -42,14 +47,14 @@ export function cellValue(value: unknown): CellPrimitive { } export function syncPayloadToTable(payload: TapSyncResponse): { - columns: Column[]; - rows: Record[]; + columns: TapTableColumn[]; + rows: Record[]; } { const syncTable = payload.resource.table; const syncColumns = syncTable.columns; - const columns: Column[] = syncColumns.map((c) => ({ name: c.name })); + const columns: TapTableColumn[] = syncColumns.map((c) => ({ name: c.name })); const rows = (syncTable.data ?? []).map((row) => { - const out: Record = {}; + const out: Record = {}; for (let i = 0; i < syncColumns.length; i++) { out[syncColumns[i].name] = cellValue(row[i]); } diff --git a/src/pages/DataCatalog.tsx b/src/pages/DataCatalog.tsx index 19e7f21..899fcc8 100644 --- a/src/pages/DataCatalog.tsx +++ b/src/pages/DataCatalog.tsx @@ -23,11 +23,7 @@ import { backendClient } from "../clients/config"; import { useDataFetching } from "../hooks/useDataFetching"; import { Loading } from "../components/core/Loading"; import { ErrorPage } from "../components/ui/ErrorPage"; -import { - CommonTable, - Column, - CellPrimitive, -} from "../components/ui/CommonTable"; +import { CommonTable, Column } from "../components/ui/CommonTable"; import { TextFilter } from "../components/core/TextFilter"; import { Accordion } from "../components/core/Accordion"; import { Text } from "../components/core/Text"; @@ -36,11 +32,11 @@ import classNames from "classnames"; import { CatalogViewTabs } from "../components/catalog/CatalogViewTabs"; import { CatalogSqlPanel } from "../components/catalog/CatalogSqlPanel"; import { - cellValue, DEFAULT_SQL_EXAMPLE, defaultSelectForTable, formatApiError, parseSqlPermalink, + syncPayloadToTable, } from "../lib/tap"; async function fetchTablesList(): Promise { @@ -250,15 +246,7 @@ function TableDetail({ hint: columnMetadataHint(c), })); - const rows: Record[] = (syncTable?.data ?? []).map( - (row) => { - const out: Record = {}; - for (let i = 0; i < syncColumns.length; i++) { - out[syncColumns[i].name] = cellValue(row[i]); - } - return out; - }, - ); + const rows = syncPayload ? syncPayloadToTable(syncPayload).rows : []; return (