From ee39e54310ddd4372a0c0ea5838f99ac5582d92d Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 22:28:58 +0200 Subject: [PATCH 1/6] feat(infocard): morphology types, unified catalogues row, leaner layout Tweaks across the galaxy/POI info cards (desktop + mobile): - Famous galaxies show their curated Hubble type ("Barred spiral (SBb)") instead of "Unknown galaxy type". New pure formatMorphology() util; the builder exposes it as its own GalaxyInfo.morphology field (independent of the colour-derived galaxyType), and the cards render `morphology ?? galaxyType.description`. - One "Catalogues" row for every galaxy, rendered under the thumbnail. The builder now emits GalaxyInfo.catalogues ({label, href}[]) with the label chosen where each URL is built, so it can't drift from the page it points at. Removes the duplicate famous-NED / bottom-"View on NED" mechanisms and the card's source branch (card is pure presentation). - Source badge (Famous/SDSS/2MRS/GLADE/category) folded onto the name row across all four cards. - "Lean hero" fold: only Redshift z + Diameter sit above "More details"; RA/Dec/apparent-mag move below. - Fix the SDSS object link: the Quick Look (quickobj) endpoint 500s for some objIds; switch to the canonical Explore Summary page. Moves famousWikipediaTitle to utils/format so the service layer no longer imports from components/. Typecheck clean; 2443 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/@types/engine/GalaxyInfo.d.ts | 42 +++-- .../InfoCard/CompactCard.module.css | 19 ++- src/components/InfoCard/CompactCard.tsx | 8 +- .../InfoCard/CompactPoiCard.module.css | 4 + src/components/InfoCard/CompactPoiCard.tsx | 6 +- src/components/InfoCard/DetailCard.module.css | 63 +++---- src/components/InfoCard/GalaxyDetailCard.tsx | 159 +++++++++--------- src/components/InfoCard/PoiDetailCard.tsx | 6 +- .../engine/helpers/galaxyInfoBuilder.ts | 48 +++++- .../format}/famousWikipediaTitle.ts | 0 src/utils/format/formatMorphology.ts | 43 +++++ src/utils/math/sdssExplorerUrl.ts | 34 ++-- .../InfoCard/InfoCard.poiHover.test.ts | 2 +- .../engine/helpers/galaxyInfoBuilder.test.ts | 42 +++-- .../format}/famousWikipediaTitle.test.ts | 8 +- tests/utils/format/formatMorphology.test.ts | 39 +++++ tests/utils/math/sdssExplorerUrl.test.ts | 8 +- 17 files changed, 323 insertions(+), 208 deletions(-) rename src/{components/InfoCard => utils/format}/famousWikipediaTitle.ts (100%) create mode 100644 src/utils/format/formatMorphology.ts rename tests/{components/InfoCard => utils/format}/famousWikipediaTitle.test.ts (82%) create mode 100644 tests/utils/format/formatMorphology.test.ts diff --git a/src/@types/engine/GalaxyInfo.d.ts b/src/@types/engine/GalaxyInfo.d.ts index 9b404ee3a..017f44d01 100644 --- a/src/@types/engine/GalaxyInfo.d.ts +++ b/src/@types/engine/GalaxyInfo.d.ts @@ -127,6 +127,18 @@ export type GalaxyInfo = { * string shown in the info card (e.g. "Red, quiescent galaxy"). */ galaxyType: GalaxyTypeInfo; + + /** + * Curated morphological (Hubble) type, pre-formatted for display + * (e.g. "Barred spiral (SBb)"), or `undefined` when none is known. + * + * Only curated famous galaxies carry a morphology; it's an independent fact + * from the colour-derived `galaxyType` (morphology says nothing about + * red-sequence vs blue-cloud membership), so it lives in its own field rather + * than overwriting `galaxyType.description`. The info card prefers it over + * the colour description when present: `morphology ?? galaxyType.description`. + */ + morphology?: string; /** * Survey-aware IAU designation, e.g. "SDSS J123456.75+012345.5", * "GLADE J234500.00-104500.5", "2MASX J...". Built from RA/Dec via @@ -199,27 +211,29 @@ export type GalaxyInfo = { /** @group External URLs */ /** - * URL of an external catalogue page for this object (opens in a new tab). + * External-catalogue links for this object, pre-labelled and ready to render + * as the InfoCard's "Catalogues" row (each opens in a new tab). Empty when + * the row has no resolvable catalogue page (e.g. Synthetic). * - * Picked per-source so every real galaxy gets a useful link: + * The builder picks both the URL and its label per-source so the label can't + * drift from the page it points at: * - * - SDSS rows with a valid objID → SDSS DR18 Quick Look (skyserver) - * - 2MRS rows → NED near-position search at the row's RA/Dec. We + * - SDSS rows with a valid objID → "SDSS Explorer" → SDSS DR18 Quick Look + * - 2MRS rows → "NED" → near-position search at the row's RA/Dec. We * deliberately don't use 2MASX byname here: NED's name index has * coverage gaps for the 2MASX prefix (verified empirically), so a - * position search lands more reliably even when one extra click - * is required to drill into the object page. - * - GLADE rows with a real PGC → NED byname `PGC ` (the PGC is + * position search lands more reliably even when one extra click is + * required to drill into the object page. + * - GLADE rows with a real PGC → "NED" → byname `PGC ` (the PGC is * persisted in `objID` — see `tools/parsers/glade.ts`) - * - GLADE rows with no PGC → NED near-position search at the row's RA/Dec - * - Famous rows → NED byname using the primary curated name - * (M31, NGC 224, …) from the famous catalog sidecar - * - Synthetic rows → `null` (no real coords to look up) + * - GLADE rows with no PGC → "NED" → near-position search at the row's RA/Dec + * - Famous rows → "NED" (byname on the primary curated name) plus a + * "Wikipedia" link resolved from the curated names. * - * The InfoCard component picks an appropriate link label off `source` - * (e.g. "View in SDSS Explorer" for SDSS, "View on NED" otherwise). + * Pre-resolving here keeps the card presentational — it just maps over the + * array — and is the single place that knows which page each label points at. */ - catalogUrl: string | null; + catalogues: Array<{ label: string; href: string }>; /** @group Physical size */ diff --git a/src/components/InfoCard/CompactCard.module.css b/src/components/InfoCard/CompactCard.module.css index 434d7e19d..c42b7b64d 100644 --- a/src/components/InfoCard/CompactCard.module.css +++ b/src/components/InfoCard/CompactCard.module.css @@ -67,23 +67,30 @@ text-transform: uppercase; } +/* Name on the left, source badge top-right — mirrors the FullCard header. */ +.headlineRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-3); +} + /* SDSS name headline */ .cardHeadline { + flex: 1; + min-width: 0; font-size: var(--font-size-xl); color: var(--color-fg); - margin-bottom: var(--space-2); letter-spacing: var(--letter-spacing-tight); word-break: break-all; } -/* - * Compact variant of the FullCard source badge. Slightly tighter padding - * and a smaller bottom margin to fit the slim preview format. - */ +/* Compact variant of the FullCard source badge — slightly tighter padding. */ .sourceBadge { display: inline-block; + flex-shrink: 0; padding: 0 5px; - margin: 0 0 var(--space-3) 0; border-radius: var(--radius-sm); background: var(--surface-badge); border: 1px solid var(--border-control); diff --git a/src/components/InfoCard/CompactCard.tsx b/src/components/InfoCard/CompactCard.tsx index b33ac9f7a..951d9867b 100644 --- a/src/components/InfoCard/CompactCard.tsx +++ b/src/components/InfoCard/CompactCard.tsx @@ -19,14 +19,16 @@ export function CompactCard({ info }: CompactCardProps): ReactNode {
Hover
-
{info.displayName}
-
{info.sourceLabel}
+
+
{info.displayName}
+ {info.sourceLabel} +
Light left {info.lookbackGyr.toFixed(1)} Gyr ago
— {info.earthEra}
- {formatDistance(info.distanceMpc)} · {info.galaxyType.description} + {formatDistance(info.distanceMpc)} · {info.morphology ?? info.galaxyType.description}
); diff --git a/src/components/InfoCard/CompactPoiCard.module.css b/src/components/InfoCard/CompactPoiCard.module.css index 39d8bbbfa..bee352bfc 100644 --- a/src/components/InfoCard/CompactPoiCard.module.css +++ b/src/components/InfoCard/CompactPoiCard.module.css @@ -29,6 +29,10 @@ composes: cardTitle from './CompactCard.module.css'; } +.headlineRow { + composes: headlineRow from './CompactCard.module.css'; +} + .cardHeadline { composes: cardHeadline from './CompactCard.module.css'; } diff --git a/src/components/InfoCard/CompactPoiCard.tsx b/src/components/InfoCard/CompactPoiCard.tsx index 4edd6a0c6..9115b3850 100644 --- a/src/components/InfoCard/CompactPoiCard.tsx +++ b/src/components/InfoCard/CompactPoiCard.tsx @@ -22,8 +22,10 @@ export function CompactPoiCard({ poi }: CompactPoiCardProps): ReactNode {
Hover
-
{poi.name}
-
{POI_CATEGORY_INFO[poi.category].shortLabel}
+
+
{poi.name}
+ {POI_CATEGORY_INFO[poi.category].shortLabel} +
{formatDistance(distanceMpc)} <> · r {formatDistance(poi.physicalRadiusMpc)} diff --git a/src/components/InfoCard/DetailCard.module.css b/src/components/InfoCard/DetailCard.module.css index aadab690b..9f558b367 100644 --- a/src/components/InfoCard/DetailCard.module.css +++ b/src/components/InfoCard/DetailCard.module.css @@ -131,15 +131,15 @@ /* ── Source attribution badge ─────────────────────────────────────────────── */ /* - * Small uppercase chip rendered just under the SDSS-name headline. Its - * job is to tell the user *which survey* the data in this card came from - * (SDSS, 2MRS, GLADE, Synthetic) — the headline name itself is a - * coordinate-derived label used across all surveys. + * Small uppercase chip telling the user *which survey* the data came from + * (SDSS, 2MRS, GLADE, Famous, Synthetic). Sits on the same row as the name + * headline, pinned to the top-right via the flex .headlineRow below — the + * survey is metadata about the name, so reading them together is natural. */ .sourceBadge { display: inline-block; + flex-shrink: 0; /* never squeeze the chip when a long name competes for width */ padding: 1px var(--space-3); - margin: var(--space-2) 0 var(--space-3) 0; border-radius: var(--radius-sm); background: var(--surface-badge); border: 1px solid var(--border-control); @@ -151,10 +151,20 @@ /* ── SDSS designation headline ────────────────────────────────────────────── */ +/* Name (+ trailing aliases) on the left, source badge top-right. */ +.headlineRow { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + margin-bottom: var(--space-4); +} + .cardHeadline { + flex: 1; + min-width: 0; /* let long names wrap/break inside the flex row */ font-size: var(--font-size-xl); color: var(--color-fg); - margin-bottom: var(--space-4); letter-spacing: var(--letter-spacing-tight); word-break: break-all; /* long names like "SDSS J..." don't overflow the card */ } @@ -272,41 +282,6 @@ details:not([open]) > .detailsSummary::before { content: '▸ '; } -/* ── External link ────────────────────────────────────────────────────────── */ - -.externalLink { - display: block; - margin-top: var(--space-5); - text-align: right; - font-size: var(--font-size-md); - color: var(--color-accent-link); - text-decoration: none; - letter-spacing: var(--letter-spacing-mid); - transition: var(--transition-color); -} -.externalLink:hover { - color: var(--color-accent); - /* - * Soft cosmic-blue glow on hover — only place in the UI that uses a - * text-shadow. Kept inline because it's a unique signature for the - * "this is a hyperlink to an external catalogue" affordance. - */ - text-shadow: 0 0 8px rgba(120, 180, 255, 0.4); -} - -/* - * Disabled-looking variant of the external link, used when the active row - * has no per-object catalogue page (i.e. non-SDSS sources). We deliberately - * reuse `.externalLink` for spacing/alignment so the visual rhythm of the - * card stays identical between the link and the placeholder — only the - * styling shifts to "muted, italic, not clickable". - */ -.externalLinkDisabled { - opacity: 0.45; - font-style: italic; - text-decoration: none; -} - /* * Description-clamp helpers. Wikipedia summaries can be paragraphs long * and would push the structured-data rows below the fold of even a @@ -386,6 +361,12 @@ details:not([open]) > .detailsSummary::before { text-decoration: underline; } +/* Muted placeholder when a row has no resolvable catalogue page at all. */ +.catalogueNone { + opacity: 0.45; + font-style: italic; +} + /* ── Focus button (only visible when the card is pinned) ──────────────────── */ /* * Sits inline with the title row to the right of the PINNED badge. Same diff --git a/src/components/InfoCard/GalaxyDetailCard.tsx b/src/components/InfoCard/GalaxyDetailCard.tsx index 7faf9cfec..a3ae8b103 100644 --- a/src/components/InfoCard/GalaxyDetailCard.tsx +++ b/src/components/InfoCard/GalaxyDetailCard.tsx @@ -1,19 +1,18 @@ /** - * GalaxyDetailCard — rich panel for a focused galaxy: thumbnail, cosmology - * summary, coordinates, expandable details, external link. + * GalaxyDetailCard — rich panel for a focused galaxy: name + survey badge, + * curated description (famous only), catalogue links, thumbnail + cosmology + * summary, and an expandable block of reference figures. * * The outer wrapper's tag + className stays stable across galaxy hover ↔ pin * transitions so the native `
` "More details" open state survives * via DOM identity (no React-state lifting needed). */ -import type { ReactNode } from 'react'; +import { Fragment, type ReactNode } from 'react'; import cx from 'classnames'; import type { GalaxyInfo } from '../../@types/engine/GalaxyInfo'; -import { Source } from '../../data/sources'; import { formatDistance, formatDiameterKpc } from '../../utils/format/distance'; import { Thumbnail } from './Thumbnail'; -import { famousWikipediaTitle } from './famousWikipediaTitle'; import { CardHeader } from './CardHeader'; import { CardRow } from './CardRow'; import { DescriptionBlock } from './DescriptionBlock'; @@ -47,50 +46,22 @@ export function GalaxyDetailCard({ onClose={pinned ? onClose : undefined} /> -
- {info.displayName} - {famousAliases.map((alias) => ( - - {' · '} - {alias} - - ))} +
+
+ {info.displayName} + {famousAliases.map((alias) => ( + + {' · '} + {alias} + + ))} +
+ {info.sourceLabel}
-
{info.sourceLabel}
- {info.famous && ( + {info.famous?.description && (
- {info.famous.description && } - {/* - Wikipedia article slug comes from `famousWikipediaTitle`, which - prefers the NGC/IC designation: Messier short ids ("M51"/"M109") - hit disambiguation pages, and non-M/C aliases (UGC/PGC/KPG) have no - article at all. NED resolves any of the names, so it keeps names[0]. - */} - - - NED - - {' · '} - - Wikipedia - - - } - /> +
)} @@ -115,35 +86,57 @@ export function GalaxyDetailCard({ {Math.round(info.hubbleVelocityKmS).toLocaleString()} km/s away
-
{info.galaxyType.description}
+
+ {info.morphology ?? info.galaxyType.description} +
RA} - value={ - <> - {info.raSexagesimal}  /  {info.ra.toFixed(4)}° - - } - /> - Dec} + label="Catalogues" value={ - <> - {info.decSexagesimal}  /  {info.dec.toFixed(4)}° - + info.catalogues.length > 0 ? ( + info.catalogues.map((link, idx) => ( + + {idx > 0 && ' · '} + + {link.label} + + + )) + ) : ( + Not catalogued + ) } /> +
+ + {/* + Above-fold "lean hero": only the two figures a casual reader cares about + after the cosmology summary — how far back the redshift puts the galaxy, + and how physically large it is. Coordinates, magnitudes, colour, and + orientation are reference data for the curious and live below the fold. + */} +
Redshift z} value={info.redshift.toFixed(4)} /> - {/* Source-aware band label: 2MRS puts J in the g-slot, GLADE puts B. */} {`Apparent mag (${info.bands.g})`}} - value={Number.isFinite(info.magG) ? info.magG.toFixed(2) : 'N/A'} + label={Diameter} + value={ + <> + {formatDiameterKpc(info.diameterKpc)} +
+ {info.diameterProvenance} + + } />
@@ -151,6 +144,27 @@ export function GalaxyDetailCard({ More details
+ RA} + value={ + <> + {info.raSexagesimal}  /  {info.ra.toFixed(4)}° + + } + /> + Dec} + value={ + <> + {info.decSexagesimal}  /  {info.dec.toFixed(4)}° + + } + /> + {/* Source-aware band label: 2MRS puts J in the g-slot, GLADE puts B. */} + {`Apparent mag (${info.bands.g})`}} + value={Number.isFinite(info.magG) ? info.magG.toFixed(2) : 'N/A'} + /> {`Absolute mag (${info.bands.g})`}} value={Number.isFinite(info.absoluteMagG) ? info.absoluteMagG.toFixed(2) : 'N/A'} @@ -179,33 +193,12 @@ export function GalaxyDetailCard({ } /> - Diameter} - value={ - <> - {formatDiameterKpc(info.diameterKpc)} -
- {info.diameterProvenance} - - } - /> {String(info.objID)}} />
- - {info.catalogUrl ? ( - - {info.source === Source.SDSS ? 'View in SDSS Explorer' : 'View on NED'} - {' →'} - - ) : ( -
- No catalogue page for {info.sourceLabel} -
- )} ); } diff --git a/src/components/InfoCard/PoiDetailCard.tsx b/src/components/InfoCard/PoiDetailCard.tsx index 233dbde98..c842de46e 100644 --- a/src/components/InfoCard/PoiDetailCard.tsx +++ b/src/components/InfoCard/PoiDetailCard.tsx @@ -49,8 +49,10 @@ export function PoiDetailCard({ onClose={pinned ? onClose : undefined} /> -
{poi.name}
-
{POI_CATEGORY_INFO[poi.category].label}
+
+
{poi.name}
+ {POI_CATEGORY_INFO[poi.category].label} +
0n) { - catalogUrl = sdssExplorerUrl(objID); + primaryCatalogue = { label: 'SDSS Explorer', href: sdssExplorerUrl(objID) }; } else if (source === Source.TwoMRS) { // 2MRS rows are routed through NED's near-position search rather // than a 2MASX byname lookup. Reason: NED's name index has @@ -260,18 +265,33 @@ export function buildGalaxyInfo( // regardless of which name NED indexes it under; the one-extra- // click on the results page is preferable to a "not recognized" // dead-end. - catalogUrl = nedNearPositionUrl(ra, dec); + primaryCatalogue = { label: 'NED', href: nedNearPositionUrl(ra, dec) }; } else if (source === Source.Glade) { - catalogUrl = objID > 0n ? nedByNameUrl(`PGC ${objID}`) : nedNearPositionUrl(ra, dec); + primaryCatalogue = { + label: 'NED', + href: objID > 0n ? nedByNameUrl(`PGC ${objID}`) : nedNearPositionUrl(ra, dec), + }; } else if (famousEntry) { - catalogUrl = nedByNameUrl(famousEntry.names[0]!); + primaryCatalogue = { label: 'NED', href: nedByNameUrl(famousEntry.names[0]!) }; } else if (source === Source.SDSS) { // SDSS row with objID = 0n (synthetic-style test fixture). Falling // back to coord search keeps the link non-null in tests so the UI // path is exercised, while pointing somewhere real-ish. - catalogUrl = nedNearPositionUrl(ra, dec); + primaryCatalogue = { label: 'NED', href: nedNearPositionUrl(ra, dec) }; } else { - catalogUrl = null; + primaryCatalogue = null; + } + // Assemble the display-ready "Catalogues" row: the primary page plus, for + // curated famous galaxies, a Wikipedia link resolved from the curated names. + const catalogues: GalaxyInfo['catalogues'] = []; + if (primaryCatalogue) catalogues.push(primaryCatalogue); + if (famousEntry) { + catalogues.push({ + label: 'Wikipedia', + href: `https://en.wikipedia.org/wiki/${encodeURIComponent( + famousWikipediaTitle(famousEntry.names).replace(/ /g, '_'), + )}`, + }); } // Size the cutout to the galaxy's angular extent so a nearby giant and a // distant dwarf both roughly fill the frame, instead of a fixed FOV that @@ -398,6 +418,14 @@ export function buildGalaxyInfo( }; } + // A curated famous row carries a real morphological (Hubble) type ('SBb', + // 'E') — the only reliable classification for entries that lack the + // photometry the colour classifier needs. Kept in its own `morphology` + // field rather than overwriting `galaxyType.description`: morphology and + // colour class are independent facts, and the card prefers morphology when + // present (`morphology ?? galaxyType.description`). + const morphology = famousEntry?.type ? formatMorphology(famousEntry.type) : undefined; + return { index: idx, objID: cloud.objIDs[idx]!, @@ -431,6 +459,7 @@ export function buildGalaxyInfo( // Derived quantities. absoluteMagG: absoluteMagnitude(magG, distanceMpc), galaxyType: galaxyType(source, { magU, magG, magR, magI, magZ }), + morphology, iauName: iauName(source, ra, dec), // Best human-readable headline for this row. Treats the choice @@ -478,8 +507,9 @@ export function buildGalaxyInfo( // doesn't define one (every non-Milliquas row today). agnClass, - // External URLs — chosen above based on `source` (and PGC for GLADE). - catalogUrl, + // External catalogue links — pre-labelled above based on `source` + // (and PGC for GLADE), plus Wikipedia for curated famous galaxies. + catalogues, thumbnailUrl, ...(thumbnailFallbackUrl !== undefined ? { thumbnailFallbackUrl } : {}), diff --git a/src/components/InfoCard/famousWikipediaTitle.ts b/src/utils/format/famousWikipediaTitle.ts similarity index 100% rename from src/components/InfoCard/famousWikipediaTitle.ts rename to src/utils/format/famousWikipediaTitle.ts diff --git a/src/utils/format/formatMorphology.ts b/src/utils/format/formatMorphology.ts new file mode 100644 index 000000000..92484736e --- /dev/null +++ b/src/utils/format/formatMorphology.ts @@ -0,0 +1,43 @@ +/** + * Expand a Hubble-stage morphology code into a human-readable galaxy type. + * + * Curated famous-galaxy entries carry a terse morphological code in the + * de Vaucouleurs / Hubble convention — 'SBb', 'E', 'S0-a' — which is precise + * but cryptic to a lay reader. We map the leading-letter family to a plain + * English class and keep the original code in parentheses for the curious: + * + * 'SBb' → 'Barred spiral (SBb)' + * 'Sbc' → 'Spiral (Sbc)' + * 'E' → 'Elliptical (E)' + * 'S0' → 'Lenticular (S0)' + * 'I' → 'Irregular (I)' + * + * The class is decided purely from the prefix, so finer stage suffixes + * ('a'/'b'/'c'/'d', bar/ring qualifiers) ride along in the parenthetical + * without needing an exhaustive lookup table. Order matters: 'S0' and 'SB' + * are tested before the generic 'S' so lenticulars and barred spirals don't + * collapse into "Spiral". + * + * An unrecognised (or empty) code is returned untouched rather than + * mislabelled — callers only invoke this with a curated, non-empty type, so + * the pass-through is a defensive default, not a routine path. + */ +export function formatMorphology(code: string): string { + const c = code.trim(); + const klass = morphologyClass(c); + return klass ? `${klass} (${c})` : c; +} + +function morphologyClass(c: string): string | null { + if (c.startsWith('dSph')) return 'Dwarf spheroidal'; + if (c.startsWith('dE')) return 'Dwarf elliptical'; + if (c.startsWith('cD')) return 'cD galaxy'; + // 'E-S0' is a transition object catalogued as elliptical-leaning lenticular. + if (c.startsWith('E')) return c.includes('S0') ? 'Elliptical/lenticular' : 'Elliptical'; + if (c.startsWith('S0')) return 'Lenticular'; + if (c.startsWith('SB')) return 'Barred spiral'; + // Catches SA / SAB (intermediate bars) and bare S?/Sa/Sb/Sc stages alike. + if (c.startsWith('S')) return 'Spiral'; + if (c.startsWith('I')) return 'Irregular'; + return null; +} diff --git a/src/utils/math/sdssExplorerUrl.ts b/src/utils/math/sdssExplorerUrl.ts index 3f78b51ae..139127167 100644 --- a/src/utils/math/sdssExplorerUrl.ts +++ b/src/utils/math/sdssExplorerUrl.ts @@ -1,30 +1,20 @@ /** - * Build the SDSS DR18 Quick Look URL for a given object identifier. + * Build the SDSS DR18 Explore-tool "Summary" URL for a given object identifier. * - * The Quick Look page shows an image cutout, photometric measurements, and - * links to the spectrum — a useful "click through to see more" target from - * the info card. + * The Explore Summary page is the canonical object page: it shows the image + * and spectrum, a summary of the photometric + spectroscopic measurements, and + * cross-survey links — a useful "click through to see more" target from the + * info card. We target Explore rather than the lightweight Quick Look + * (`quickobj`) endpoint, which returns a server error for some objIds. * - * SDSS objIDs are 64-bit unsigned integers that exceed Number's safe integer - * limit, so we accept `bigint` to avoid silent truncation of the last digits. - */ - -/** - * Build the URL of the SDSS Quick Look page for an object. - * - * Opens a web page showing an image cutout, photometric measurements, and - * links to the spectrum. - * - * `objID` is a 64-bit unsigned integer. We accept `bigint` here to preserve - * full precision — SDSS objIDs are 18-digit numbers that exceed Number's - * safe integer limit (2⁵³ ≈ 9 × 10¹⁵), so passing them as `number` would - * silently truncate the last few digits and retrieve the wrong object. + * `objId` is a 64-bit unsigned integer. We accept `bigint` to preserve full + * precision — SDSS objIDs are 18-digit numbers that exceed Number's safe + * integer limit (2⁵³ ≈ 9 × 10¹⁵), so passing them as `number` would silently + * truncate the last few digits and retrieve the wrong object. * * URL template (DR18): - * https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId={objId} - * - * @param objId The SDSS 64-bit object identifier as a bigint. + * https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId={objId} */ export function sdssExplorerUrl(objId: bigint): string { - return `https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=${objId}`; + return `https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=${objId}`; } diff --git a/tests/components/InfoCard/InfoCard.poiHover.test.ts b/tests/components/InfoCard/InfoCard.poiHover.test.ts index b048cd08c..f7f0e5489 100644 --- a/tests/components/InfoCard/InfoCard.poiHover.test.ts +++ b/tests/components/InfoCard/InfoCard.poiHover.test.ts @@ -78,7 +78,7 @@ const galaxyStub = { absoluteMagG: 0, iauName: 'IAU NGC 1234', source: 0, - catalogUrl: null, + catalogues: [], diameterKpc: 30, diameterProvenance: 'fallback (30 kpc)', orientation: { axisRatio: 1, positionAngleDeg: 0, provenance: 'fallback' }, diff --git a/tests/services/engine/helpers/galaxyInfoBuilder.test.ts b/tests/services/engine/helpers/galaxyInfoBuilder.test.ts index a4526f776..388d45d2a 100644 --- a/tests/services/engine/helpers/galaxyInfoBuilder.test.ts +++ b/tests/services/engine/helpers/galaxyInfoBuilder.test.ts @@ -187,11 +187,11 @@ describe('buildGalaxyInfo — SDSS source', () => { // SDSS prefix on the IAU name; coords match the rounded-down truncation. expect(info.iauName.startsWith('SDSS J')).toBe(true); - // catalogUrl points to the SDSS Quick Look page for SDSS rows with a - // valid objID (> 0n). - expect(info.catalogUrl).not.toBeNull(); - expect(info.catalogUrl).toContain('skyserver.sdss.org'); - expect(info.catalogUrl).toContain('objId=1'); + // The primary catalogue link points to the SDSS Quick Look page, labelled + // "SDSS Explorer", for SDSS rows with a valid objID (> 0n). + expect(info.catalogues[0]!.label).toBe('SDSS Explorer'); + expect(info.catalogues[0]!.href).toContain('skyserver.sdss.org'); + expect(info.catalogues[0]!.href).toContain('objId=1'); // SDSS rows use the SDSS thumbnail URL (not DSS). expect(info.thumbnailUrl).toContain('skyserver.sdss.org'); @@ -225,9 +225,9 @@ describe('buildGalaxyInfo — SDSS source', () => { cloud.objIDs[0] = 0n; setPosition(cloud, 0, 100, 0, 0); const info = buildGalaxyInfo(cloud, 0, Source.SDSS); - expect(info.catalogUrl).not.toBeNull(); - expect(info.catalogUrl).toContain('ned.ipac.caltech.edu'); - expect(info.catalogUrl).toContain('Near+Position+Search'); + expect(info.catalogues[0]!.label).toBe('NED'); + expect(info.catalogues[0]!.href).toContain('ned.ipac.caltech.edu'); + expect(info.catalogues[0]!.href).toContain('Near+Position+Search'); }); it('flags orientation provenance as "deterministic fallback" when ar/pa match the hash', () => { @@ -268,9 +268,9 @@ describe('buildGalaxyInfo — TwoMRS source', () => { // 2MASX byname lookup — NED's name index has coverage gaps for // 2MASX even when the underlying object is present in NED under a // different catalogue name. See galaxyInfoBuilder.ts for why. - expect(info.catalogUrl).not.toBeNull(); - expect(info.catalogUrl).toContain('ned.ipac.caltech.edu'); - expect(info.catalogUrl).toContain('Near+Position+Search'); + expect(info.catalogues[0]!.label).toBe('NED'); + expect(info.catalogues[0]!.href).toContain('ned.ipac.caltech.edu'); + expect(info.catalogues[0]!.href).toContain('Near+Position+Search'); // Thumbnail comes from the CDS hips2fits DSS proxy, not SDSS ImgCutout. expect(info.thumbnailUrl).toContain('alasky.cds.unistra.fr'); @@ -336,9 +336,9 @@ describe('buildGalaxyInfo — Glade source', () => { expect(info.sourceLabel).toBe('GLADE'); expect(info.iauName.startsWith('GLADE J')).toBe(true); // GLADE row with PGC = 0n → NED coord-search URL. - expect(info.catalogUrl).not.toBeNull(); - expect(info.catalogUrl).toContain('ned.ipac.caltech.edu'); - expect(info.catalogUrl).toContain('Near+Position+Search'); + expect(info.catalogues[0]!.label).toBe('NED'); + expect(info.catalogues[0]!.href).toContain('ned.ipac.caltech.edu'); + expect(info.catalogues[0]!.href).toContain('Near+Position+Search'); expect(info.thumbnailUrl).toContain('alasky.cds.unistra.fr'); expect(info.bands).toEqual({ u: '—', g: 'B', r: 'J', i: 'H', z: 'K' }); @@ -359,8 +359,9 @@ describe('buildGalaxyInfo — Glade source', () => { setPosition(cloud, 0, 0, 0, 200); cloud.objIDs[0] = 12345n; const info = buildGalaxyInfo(cloud, 0, Source.Glade); - expect(info.catalogUrl).toContain('ned.ipac.caltech.edu/byname'); - expect(info.catalogUrl).toContain('PGC+12345'); + expect(info.catalogues[0]!.label).toBe('NED'); + expect(info.catalogues[0]!.href).toContain('ned.ipac.caltech.edu/byname'); + expect(info.catalogues[0]!.href).toContain('PGC+12345'); }); }); @@ -388,7 +389,7 @@ describe('buildGalaxyInfo — Synthetic source', () => { expect(info.source).toBe(Source.Synthetic); expect(info.sourceLabel).toBe('Synthetic'); expect(info.iauName.startsWith('Synth J')).toBe(true); - expect(info.catalogUrl).toBeNull(); + expect(info.catalogues).toEqual([]); expect(info.thumbnailUrl).toContain('alasky.cds.unistra.fr'); expect(info.orientation.provenance).toBe('deterministic fallback'); }); @@ -426,6 +427,13 @@ describe('buildGalaxyInfo — Famous source', () => { expect(info.famous!.id).toBe('m31'); expect(info.famous!.names).toEqual(['M31', 'Andromeda Galaxy']); expect(info.famous!.description).toBe('Nearest large spiral.'); + + // The curated morphology is exposed on its own field (the colour + // classifier yields "Unknown galaxy type" for a row with no photometry). + expect(info.morphology).toBe('Barred spiral (SBb)'); + + // Famous rows carry a NED + Wikipedia catalogue pair. + expect(info.catalogues.map((c) => c.label)).toEqual(['NED', 'Wikipedia']); }); it('omits the famous block when the sidecar is undefined (graceful degradation)', () => { diff --git a/tests/components/InfoCard/famousWikipediaTitle.test.ts b/tests/utils/format/famousWikipediaTitle.test.ts similarity index 82% rename from tests/components/InfoCard/famousWikipediaTitle.test.ts rename to tests/utils/format/famousWikipediaTitle.test.ts index efc67a1df..162d81a36 100644 --- a/tests/components/InfoCard/famousWikipediaTitle.test.ts +++ b/tests/utils/format/famousWikipediaTitle.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { famousWikipediaTitle } from '../../../src/components/InfoCard/famousWikipediaTitle'; +import { famousWikipediaTitle } from '../../../src/utils/format/famousWikipediaTitle'; describe('famousWikipediaTitle', () => { it('prefers the NGC/IC designation over a Messier short id', () => { @@ -11,9 +11,9 @@ describe('famousWikipediaTitle', () => { it('keeps the NGC name when it is first and the aliases would 404', () => { // NGC-primary galaxies carry UGC/PGC/KPG aliases that have no Wikipedia // article — picking names[1] blindly would link to a 404. - expect( - famousWikipediaTitle(['NGC 3166', 'UGC 5516', 'PGC 29814', 'KPG 228A']), - ).toBe('NGC 3166'); + expect(famousWikipediaTitle(['NGC 3166', 'UGC 5516', 'PGC 29814', 'KPG 228A'])).toBe( + 'NGC 3166', + ); }); it('handles an IC designation anywhere in the list', () => { diff --git a/tests/utils/format/formatMorphology.test.ts b/tests/utils/format/formatMorphology.test.ts new file mode 100644 index 000000000..12064e2e9 --- /dev/null +++ b/tests/utils/format/formatMorphology.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { formatMorphology } from '../../../src/utils/format/formatMorphology'; + +describe('formatMorphology', () => { + it('labels barred spirals from an SB prefix', () => { + expect(formatMorphology('SBb')).toBe('Barred spiral (SBb)'); + expect(formatMorphology('SBcd')).toBe('Barred spiral (SBcd)'); + expect(formatMorphology('SBm')).toBe('Barred spiral (SBm)'); + }); + + it('labels unbarred and intermediate spirals as Spiral', () => { + expect(formatMorphology('Sbc')).toBe('Spiral (Sbc)'); + expect(formatMorphology('Sa')).toBe('Spiral (Sa)'); + expect(formatMorphology('SABb')).toBe('Spiral (SABb)'); + expect(formatMorphology('S?')).toBe('Spiral (S?)'); + }); + + it('labels ellipticals and lenticulars', () => { + expect(formatMorphology('E')).toBe('Elliptical (E)'); + expect(formatMorphology('S0')).toBe('Lenticular (S0)'); + expect(formatMorphology('S0-a')).toBe('Lenticular (S0-a)'); + expect(formatMorphology('E-S0')).toBe('Elliptical/lenticular (E-S0)'); + }); + + it('labels irregulars', () => { + expect(formatMorphology('I')).toBe('Irregular (I)'); + expect(formatMorphology('IB')).toBe('Irregular (IB)'); + }); + + it('labels dwarf morphologies', () => { + expect(formatMorphology('dSph')).toBe('Dwarf spheroidal (dSph)'); + expect(formatMorphology('dE3')).toBe('Dwarf elliptical (dE3)'); + }); + + it('passes an unrecognised or empty code through untouched', () => { + expect(formatMorphology('???')).toBe('???'); + expect(formatMorphology('')).toBe(''); + }); +}); diff --git a/tests/utils/math/sdssExplorerUrl.test.ts b/tests/utils/math/sdssExplorerUrl.test.ts index ccd317eae..339379dc6 100644 --- a/tests/utils/math/sdssExplorerUrl.test.ts +++ b/tests/utils/math/sdssExplorerUrl.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for `sdssExplorerUrl` — pure URL builder for the SDSS DR18 - * Quick Look page. + * Explore-tool Summary page. * * The function takes a 64-bit `bigint` objID and interpolates it into a * canonical URL. Tests verify the URL shape and that bigint values @@ -12,11 +12,11 @@ import { describe, it, expect } from 'vitest'; import { sdssExplorerUrl } from '../../../src/utils/math/sdssExplorerUrl'; describe('sdssExplorerUrl', () => { - it('produces a DR18 quickobj URL for a small objID', () => { + it('produces a DR18 Explore Summary URL for a small objID', () => { // Spot-check the literal URL template in the source. Any change to the // path or the query-param name would break this. expect(sdssExplorerUrl(42n)).toBe( - 'https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=42', + 'https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=42', ); }); @@ -34,7 +34,7 @@ describe('sdssExplorerUrl', () => { // GalaxyInfo layer, but the URL builder itself is permissive and just // string-interpolates the value. expect(sdssExplorerUrl(0n)).toBe( - 'https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=0', + 'https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=0', ); }); }); From 3fc168c7e9bdae38cb574fd71af7bc333ed92964 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 23:03:28 +0200 Subject: [PATCH 2/6] docs(infocard): spec the mobile bottom-sheet info card Captures the brainstormed Option A (scroll-snap bottom sheet, minimal peek, drag gesture) for the selected-target card on mobile. Panel launcher is a gated fast-follow pending an entanglement-radar pass over SettingsPanel. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...07-mobile-info-card-bottom-sheet-design.md | 156 ++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md diff --git a/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md b/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md new file mode 100644 index 000000000..8fb07c587 --- /dev/null +++ b/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md @@ -0,0 +1,156 @@ +# Mobile info-card bottom sheet — design + +**Date:** 2026-06-07 +**Status:** Awaiting review +**Scope:** The selected-target info card on mobile (< 768 px). Desktop is untouched. + +## Problem + +On a phone, selecting a galaxy or structure makes the info card cover most of +the screen — including the galaxy/structure it describes, which renders in the +centre. The card is `position: fixed` top-right with `max-width: 320px` and **no +mobile media query** (`DetailCard.module.css:22`, `InfoCard.module.css:17`), so +at a 360–390 px viewport it spans ~85 % of the width and runs the full height of +the content. The user reads the data but loses sight of the thing. + +A second, related complaint — the three bottom-left panels (Settings / Stats / +Navigation) overrunning the bottom-right scale bar — is **out of scope here** and +captured as a gated fast-follow (see the last section). + +## Goals + +- On mobile, a selected galaxy/structure stays **visible** — the card no longer + covers the centre. +- The full detail set is still **one gesture away**. +- The scale bar no longer sits under the card. +- **Desktop (≥ 768 px) is byte-for-byte unchanged** — same top-right card, same + panels, same behaviour. +- Reuse the existing `GalaxyDetailCard` / `PoiDetailCard` bodies; don't fork a + parallel mobile card with its own drifting content. + +## Non-goals + +- The Settings/Stats/Navigation panel launcher (fast-follow, gated on an + entanglement-radar pass over `SettingsPanel`). +- Any change to what data the card shows, or to `galaxyInfoBuilder`. +- Live re-layout on orientation change beyond what CSS media queries give for + free (the existing one-shot `initialMobile` sample stays as-is). + +## Chosen direction — bottom sheet (Option A) + +Selected via the visual-companion brainstorm. On mobile the card becomes a +**bottom sheet** with two states: + +| State | What shows | When | +| --- | --- | --- | +| **Collapsed (peek)** | Grab handle, name + survey badge, one line ` · ago` (galaxy) / ` · ` (structure). ~2 lines tall. | Default the moment something is selected. The object stays in view above. | +| **Expanded** | The full existing detail-card body, scrolling internally, capped so the sky still peeks at the top (~75 vh). | After the user drags/taps the sheet up. | + +### Peek content (decided: minimal) + +- **Galaxy:** `displayName` + `sourceLabel` badge, then `formatDistance(distanceMpc) · lookbackGyr Gyr ago`. +- **Structure:** `name` + category-label badge, then `category · formatDistance`. + +Everything else (thumbnail, cosmology lines, catalogues, redshift, diameter, the +existing "More details" fold) lives in the expanded state. The peek is the +**top slice of the same card** revealed by the sheet's collapsed snap — not a +separate component — so there is no second copy of the headline to keep in sync. + +### Gesture (decided: drag, via CSS scroll-snap) + +The drag is a **scroll-snap bottom sheet** — pure CSS for the gesture: + +- The sheet lives in a `position: fixed; inset: 0` vertical scroll container with + `scroll-snap-type: y mandatory` and two snap children: **peek** and + **expanded**. +- Dragging the sheet is native touch scrolling; the browser owns the momentum + and the snap. No JS for the gesture. +- A transparent spacer fills the area above the sheet with `pointer-events: + none`, so taps/drags on the sky still reach the canvas; only the sheet itself + has `pointer-events: auto`. + +**The one piece that is not pure CSS:** when the user selects a *different* +object (and on the ✕ close), the container must scroll back to the peek snap. +That is a ~3-line `useEffect` keyed on the selected target's identity calling +`ref.scrollTo({ top: peekOffset })`. Selection is already React state, so this +adds no new state — only a reset. (If we ever want *zero* JS, the fallback is a +`
` tap-toggle with no drag; we are not taking that.) + +### Hover on mobile + +Touch has no hover, so on mobile the sheet renders **only `selected`**. The +compact hover preview (`CompactCard` / `CompactPoiCard`) and the stacked-pair +layout are desktop-only and are suppressed below the breakpoint. `InfoCard` +already returns null when nothing is selected; the mobile branch simply ignores +`hovered`. + +### Scale bar + +When a sheet is present on mobile, the scale bar lifts to sit **above** the +collapsed peek (it is irrelevant while reading details and is allowed to be +covered when the sheet is expanded). Implementation: the UI-stack root already +knows selection state in React; it carries a class (e.g. `hasSelection`) that a +mobile-only media rule reads to raise the scale bar's `bottom` offset by the peek +height. No new state, no JS measurement. + +## Component / contract changes + +- **`InfoCard.tsx`** — add a mobile branch. Above the breakpoint: today's + stack, unchanged. Below it: a `MobileSheet` wrapper around the single + `GalaxyDetailCard` / `PoiDetailCard` for `selected`, with `hovered` ignored. + Layout switches via CSS media queries wherever possible; JS reads the + breakpoint (via `matchMedia`) only for what CSS can't do — choosing the + mobile render branch and the scroll-reset. The plan decides whether that read + is a new `useIsMobile()` hook or a reuse of the existing `initialMobile` + sample. +- **New `MobileSheet` component + CSS module** — the scroll-snap container, the + spacer, the grab handle, the peek/expanded snap children, and the + scroll-reset effect. Contains a detail card as its child; knows nothing about + *which* card. +- **`DetailCard.module.css`** — a mobile media block that, inside the sheet, + reorders/restyles so the card's top is exactly the peek (headline + a single + compact distance line) and the remainder flows below the expanded snap line. + No desktop rules change. +- **`App.module.css` + the UI-stack root** — the `hasSelection` class hook and + the mobile scale-bar offset rule. + +No changes to `GalaxyInfo`, `galaxyInfoBuilder`, the engine, or any data path. + +## Testing strategy + +- **Unit (jsdom + Testing Library):** `InfoCard` renders only the selected + sheet on mobile and ignores `hovered`; renders today's stack on desktop. + Drive the breakpoint by mocking `matchMedia`. Assert peek content (name, + badge, the single distance line) is present and that "More details" reference + rows are in the DOM (the scroll-snap reveal is CSS — we assert content + presence, not pixel position, matching the existing + `InfoCard.poiHover.test.ts` philosophy of testing rendered text, not class + fragments). +- **Reset effect:** changing the `selected` target calls `scrollTo` on the + container ref (spy). +- **Desktop parity:** existing `InfoCard` tests stay green unchanged — the + desktop branch must be untouched. +- **Visual:** manual check on the dev server at a phone width (the gesture and + snap are browser-native and not unit-testable). + +## Risks / open questions + +- **`pointer-events` passthrough vs. the canvas' `touch-action: none`.** Need to + confirm on a real device that dragging the sheet scrolls it while dragging the + sky still orbits the camera. Mitigation: the spacer is `pointer-events: none`; + only the sheet captures touch. +- **Peek height as a CSS constant.** The scale-bar lift and the peek snap both + depend on the peek height; it should be one token, not two literals, to avoid + drift. +- **`useIsMobile` vs. pure CSS.** Prefer CSS media queries for layout; use JS + only for the scroll-reset and the `hasSelection` hook. The plan should resist + introducing a JS `isMobile` branch where a media query suffices. + +## Fast-follow (gated, not part of this spec) + +Collapse Settings / Stats / Navigation behind a single `⚙` launcher on mobile, +sharing a "utility row" with the scale bar so they stop overrunning it. **This is +gated on an entanglement-radar review of `SettingsPanel`** — it is likely tangled +(value × place mirror state, a god-panel of independent toggles), and we want to +un-braid it before bolting on a mobile presentation. Separate spec + plan after +that review. From 329d55c36e34f3d002991f86a7cc14b2aa1a261c Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 23:13:48 +0200 Subject: [PATCH 3/6] refactor(infocard): rename PoiDetailCard to StructureDetailCard Renames the structure-card family from the legacy 'poi' naming to 'structure', matching the StructureRecord data type it already consumes: PoiDetailCard -> StructureDetailCard, CompactPoiCard -> CompactStructureCard, and the poi prop/vars -> structure throughout InfoCard. The card eyebrow now reads 'Structure' instead of 'POI'. Engine-wide identifiers (isPoi, POI_CATEGORY_INFO, the #poi= deep-link param) are intentionally left as a separate, larger migration. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/InfoCard/CompactPoiCard.tsx | 35 --------------- ...le.css => CompactStructureCard.module.css} | 2 +- .../InfoCard/CompactStructureCard.tsx | 39 ++++++++++++++++ src/components/InfoCard/DescriptionBlock.tsx | 12 ++--- src/components/InfoCard/DetailCard.module.css | 6 +-- src/components/InfoCard/InfoCard.tsx | 39 ++++++++-------- ...DetailCard.tsx => StructureDetailCard.tsx} | 44 ++++++++++--------- ...d.test.ts => CompactStructureCard.test.ts} | 16 +++---- .../InfoCard/DescriptionBlock.test.ts | 2 +- ...est.ts => InfoCard.structureHover.test.ts} | 41 +++++++++-------- ...rd.test.ts => StructureDetailCard.test.ts} | 18 ++++---- 11 files changed, 131 insertions(+), 123 deletions(-) delete mode 100644 src/components/InfoCard/CompactPoiCard.tsx rename src/components/InfoCard/{CompactPoiCard.module.css => CompactStructureCard.module.css} (94%) create mode 100644 src/components/InfoCard/CompactStructureCard.tsx rename src/components/InfoCard/{PoiDetailCard.tsx => StructureDetailCard.tsx} (65%) rename tests/components/InfoCard/{CompactPoiCard.test.ts => CompactStructureCard.test.ts} (77%) rename tests/components/InfoCard/{InfoCard.poiHover.test.ts => InfoCard.structureHover.test.ts} (74%) rename tests/components/InfoCard/{PoiDetailCard.test.ts => StructureDetailCard.test.ts} (72%) diff --git a/src/components/InfoCard/CompactPoiCard.tsx b/src/components/InfoCard/CompactPoiCard.tsx deleted file mode 100644 index 9115b3850..000000000 --- a/src/components/InfoCard/CompactPoiCard.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/** - * CompactPoiCard — slim hover-preview for a cluster / supercluster / void. - * POI variant of CompactCard, kept as a sibling file so the two can diverge - * (different distance derivation, different secondary line). - */ - -import type { ReactNode } from 'react'; -import type { StructureRecord } from '../../@types/engine/data/StructureRecord'; -import { formatDistance } from '../../utils/format/distance'; -import { POI_CATEGORY_INFO } from '../../data/poiCategoryInfo'; -import styles from './CompactPoiCard.module.css'; - -export type CompactPoiCardProps = { - poi: StructureRecord; -}; - -export function CompactPoiCard({ poi }: CompactPoiCardProps): ReactNode { - const distanceMpc = Math.hypot(poi.worldPos[0], poi.worldPos[1], poi.worldPos[2]); - - return ( -
-
- Hover -
-
-
{poi.name}
- {POI_CATEGORY_INFO[poi.category].shortLabel} -
-
- {formatDistance(distanceMpc)} - <> · r {formatDistance(poi.physicalRadiusMpc)} -
-
- ); -} diff --git a/src/components/InfoCard/CompactPoiCard.module.css b/src/components/InfoCard/CompactStructureCard.module.css similarity index 94% rename from src/components/InfoCard/CompactPoiCard.module.css rename to src/components/InfoCard/CompactStructureCard.module.css index bee352bfc..959e81781 100644 --- a/src/components/InfoCard/CompactPoiCard.module.css +++ b/src/components/InfoCard/CompactStructureCard.module.css @@ -1,5 +1,5 @@ /* - * CompactPoiCard.module.css — POI variant of the slim hover-preview card. + * CompactStructureCard.module.css — structure variant of the slim hover-preview card. * * Visual weight is identical to the galaxy CompactCard — only the * content differs. Rather than copy-pasting the chrome rules from diff --git a/src/components/InfoCard/CompactStructureCard.tsx b/src/components/InfoCard/CompactStructureCard.tsx new file mode 100644 index 000000000..6d51b3110 --- /dev/null +++ b/src/components/InfoCard/CompactStructureCard.tsx @@ -0,0 +1,39 @@ +/** + * CompactStructureCard — slim hover-preview for a cluster / supercluster / void. + * Structure variant of CompactCard, kept as a sibling file so the two can + * diverge (different distance derivation, different secondary line). + */ + +import type { ReactNode } from 'react'; +import type { StructureRecord } from '../../@types/engine/data/StructureRecord'; +import { formatDistance } from '../../utils/format/distance'; +import { POI_CATEGORY_INFO } from '../../data/poiCategoryInfo'; +import styles from './CompactStructureCard.module.css'; + +export type CompactStructureCardProps = { + structure: StructureRecord; +}; + +export function CompactStructureCard({ structure }: CompactStructureCardProps): ReactNode { + const distanceMpc = Math.hypot( + structure.worldPos[0], + structure.worldPos[1], + structure.worldPos[2], + ); + + return ( +
+
+ Hover +
+
+
{structure.name}
+ {POI_CATEGORY_INFO[structure.category].shortLabel} +
+
+ {formatDistance(distanceMpc)} + <> · r {formatDistance(structure.physicalRadiusMpc)} +
+
+ ); +} diff --git a/src/components/InfoCard/DescriptionBlock.tsx b/src/components/InfoCard/DescriptionBlock.tsx index ac359a5a4..172cf1c24 100644 --- a/src/components/InfoCard/DescriptionBlock.tsx +++ b/src/components/InfoCard/DescriptionBlock.tsx @@ -3,12 +3,12 @@ * toggle, stacked as a column so the toggle sits underneath the text rather * than floating to its right. * - * Shared by GalaxyDetailCard (the famous-galaxy blurb) and PoiDetailCard (the - * cluster / supercluster / void / group blurb). Both cards used to inline this - * markup, but each chose a different wrapper — the galaxy card stacked it in a - * column while the POI card borrowed the label/value CardRow shape, so the - * "show more" affordance landed in a different place in each. Extracting it - * here means the two cards read identically. + * Shared by GalaxyDetailCard (the famous-galaxy blurb) and StructureDetailCard + * (the cluster / supercluster / void / group blurb). Both cards used to inline + * this markup, but each chose a different wrapper — the galaxy card stacked it + * in a column while the structure card borrowed the label/value CardRow shape, + * so the "show more" affordance landed in a different place in each. Extracting + * it here means the two cards read identically. * * Owns its own collapse state: each card mounts one block per description and * the expanded/collapsed flag is local UI, never lifted. The 5-line clamp and diff --git a/src/components/InfoCard/DetailCard.module.css b/src/components/InfoCard/DetailCard.module.css index 9f558b367..fe9872501 100644 --- a/src/components/InfoCard/DetailCard.module.css +++ b/src/components/InfoCard/DetailCard.module.css @@ -56,14 +56,14 @@ } /* - * POI variant — short content (name + category + distance + radius) would + * Structure variant — short content (name + category + distance + radius) would * otherwise collapse the card to its 240 px min-width, making it visually * narrower than the galaxy card which naturally fills to ~320 px from its * cosmology block. Pinning min-width to --card-max-width keeps the chrome - * width identical across galaxy↔POI swaps, so the user reads the swap as + * width identical across galaxy↔structure swaps, so the user reads the swap as * "different content, same panel" instead of "the panel shrank". */ -.poi { +.structure { min-width: var(--card-max-width); } diff --git a/src/components/InfoCard/InfoCard.tsx b/src/components/InfoCard/InfoCard.tsx index 2968c87ee..858f49a54 100644 --- a/src/components/InfoCard/InfoCard.tsx +++ b/src/components/InfoCard/InfoCard.tsx @@ -11,7 +11,7 @@ * (`GalaxyInfo | StructureRecord`). App.tsx merges POI and galaxy state * before handing them here — POI wins when both are present. InfoCard then * dispatches via `isPoi` into typed sub-slots and picks the right detail-card - * variant (`GalaxyDetailCard` vs `PoiDetailCard`). + * variant (`GalaxyDetailCard` vs `StructureDetailCard`). */ import type { ReactNode } from 'react'; @@ -20,22 +20,22 @@ import type { GalaxyInfo } from '../../@types/engine/GalaxyInfo'; import type { FocusableTarget } from '../../@types/engine/FocusableTarget'; import { isPoi } from '../../services/engine/isPoi'; import { GalaxyDetailCard } from './GalaxyDetailCard'; -import { PoiDetailCard } from './PoiDetailCard'; +import { StructureDetailCard } from './StructureDetailCard'; import { CompactCard } from './CompactCard'; -import { CompactPoiCard } from './CompactPoiCard'; +import { CompactStructureCard } from './CompactStructureCard'; import styles from './InfoCard.module.css'; export type InfoCardProps = { /** * The point currently under the cursor, or null when the cursor is on empty - * sky. Can be either a galaxy or a POI — InfoCard dispatches via `isPoi` - * to render the appropriate hover variant. + * sky. Can be either a galaxy or a structure — InfoCard dispatches via + * `isPoi` to render the appropriate hover variant. */ hovered: FocusableTarget | null; /** * The pinned/selected target, or null when nothing is pinned. Same dispatch * as `hovered`. When both `hovered` and `selected` are non-null and of the - * same kind (galaxy/galaxy or poi/poi), the stacked-pair layout applies; + * same kind (galaxy/galaxy or structure/structure), the stacked-pair layout applies; * when they're different kinds (e.g. galaxy pinned, POI hovered), both render * in their respective slots. */ @@ -43,7 +43,7 @@ export type InfoCardProps = { /** * Catalogued galaxy count for the pinned structure (cluster / supercluster * / void), or null/undefined when not applicable. Forwarded to - * PoiDetailCard, which renders it as the "Galaxies" row. Ignored for + * StructureDetailCard, which renders it as the "Galaxies" row. Ignored for * galaxy selections (GalaxyDetailCard has no such row). */ selectedMemberCount?: number | null; @@ -74,30 +74,31 @@ export function InfoCard({ // Dispatch via isPoi into typed sub-slots. A StructureRecord is identified // by a top-level `category` field; GalaxyInfo carries category only at // `galaxyType.category`. See isPoi.ts for the discriminant rationale. - const selectedPoi = selected && isPoi(selected) ? selected : null; + const selectedStructure = selected && isPoi(selected) ? selected : null; const selectedGalaxy = selected && !isPoi(selected) ? (selected as GalaxyInfo) : null; - const hoveredPoi = hovered && isPoi(hovered) ? hovered : null; + const hoveredStructure = hovered && isPoi(hovered) ? hovered : null; const hoveredGalaxy = hovered && !isPoi(hovered) ? (hovered as GalaxyInfo) : null; - // POI hover wins over galaxy hover when both are non-null (a transient + // Structure hover wins over galaxy hover when both are non-null (a transient // cross-render race; the engine's hover throttler normally clears the - // "other" sink). POI hover is also suppressed when the SAME POI is - // already pinned — PoiDetailCard above already shows that content. - const showPoiHover = hoveredPoi != null && hoveredPoi.id !== selectedPoi?.id; - const showGalaxyHover = hoveredGalaxy != null && !showPoiHover; + // "other" sink). Structure hover is also suppressed when the SAME structure + // is already pinned — StructureDetailCard above already shows that content. + const showStructureHover = + hoveredStructure != null && hoveredStructure.id !== selectedStructure?.id; + const showGalaxyHover = hoveredGalaxy != null && !showStructureHover; - if (selectedPoi) { + if (selectedStructure) { return (
- {showGalaxyHover && } - {showPoiHover && } + {showStructureHover && }
); } @@ -122,7 +123,7 @@ export function InfoCard({ /> )} {isStacked && } - {showPoiHover && } + {showStructureHover && }
); } diff --git a/src/components/InfoCard/PoiDetailCard.tsx b/src/components/InfoCard/StructureDetailCard.tsx similarity index 65% rename from src/components/InfoCard/PoiDetailCard.tsx rename to src/components/InfoCard/StructureDetailCard.tsx index c842de46e..e983b287a 100644 --- a/src/components/InfoCard/PoiDetailCard.tsx +++ b/src/components/InfoCard/StructureDetailCard.tsx @@ -1,5 +1,5 @@ /** - * PoiDetailCard — rich panel for a focused cluster / supercluster / void. + * StructureDetailCard — rich panel for a focused cluster / supercluster / void. * Shows name, category, distance from observer, physical radius, and — for * clusters carrying one — the Abell/ACO designation. */ @@ -16,42 +16,46 @@ import { InfoTip } from '../InfoTip/InfoTip'; import { TIPS } from './tooltips'; import styles from './DetailCard.module.css'; -export type PoiDetailCardProps = { - poi: StructureRecord; +export type StructureDetailCardProps = { + structure: StructureRecord; pinned?: boolean; /** * Catalogued galaxies inside this structure's membership sphere at the * current tier + survey visibility, or null/undefined when not countable - * (famous-galaxy POI, or catalogs not loaded yet) — in which case the + * (famous-galaxy structure, or catalogs not loaded yet) — in which case the * row is omitted rather than flashing a misleading "0". */ memberCount?: number | null; - onFocus?: (poi: StructureRecord) => void; + onFocus?: (structure: StructureRecord) => void; onClose?: () => void; }; -export function PoiDetailCard({ - poi, +export function StructureDetailCard({ + structure, pinned = false, memberCount, onFocus, onClose, -}: PoiDetailCardProps): ReactNode { - const distanceMpc = Math.hypot(poi.worldPos[0], poi.worldPos[1], poi.worldPos[2]); - const outerClass = `${styles.infoCardFull} ${styles.poi}${pinned ? ` ${styles.pinned}` : ''}`; +}: StructureDetailCardProps): ReactNode { + const distanceMpc = Math.hypot( + structure.worldPos[0], + structure.worldPos[1], + structure.worldPos[2], + ); + const outerClass = `${styles.infoCardFull} ${styles.structure}${pinned ? ` ${styles.pinned}` : ''}`; return (
onFocus(poi) : undefined} - focusAriaLabel={`Focus camera on ${poi.name}`} + eyebrow="Structure" + onFocus={pinned && onFocus ? () => onFocus(structure) : undefined} + focusAriaLabel={`Focus camera on ${structure.name}`} onClose={pinned ? onClose : undefined} />
-
{poi.name}
- {POI_CATEGORY_INFO[poi.category].label} +
{structure.name}
+ {POI_CATEGORY_INFO[structure.category].label}
@@ -61,7 +65,7 @@ export function PoiDetailCard({ /> Radius} - value={formatDistance(poi.physicalRadiusMpc)} + value={formatDistance(structure.physicalRadiusMpc)} /> {memberCount != null && ( )} - {poi.category === 'cluster' && poi.abell !== undefined && ( + {structure.category === 'cluster' && structure.abell !== undefined && ( Abell} - value={formatAbellDesignation(poi.abell)} + value={formatAbellDesignation(structure.abell)} /> )} - {poi.description && ( + {structure.description && ( // Curated Wikipedia-lead blurb (featured anchors) or the build's // auto one-liner (bulk entries). Shares DescriptionBlock with // GalaxyDetailCard so the show-more toggle sits in the same place. - + )}
diff --git a/tests/components/InfoCard/CompactPoiCard.test.ts b/tests/components/InfoCard/CompactStructureCard.test.ts similarity index 77% rename from tests/components/InfoCard/CompactPoiCard.test.ts rename to tests/components/InfoCard/CompactStructureCard.test.ts index cfd1aa79e..d97123b0a 100644 --- a/tests/components/InfoCard/CompactPoiCard.test.ts +++ b/tests/components/InfoCard/CompactStructureCard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// CompactPoiCard — rendering tests for the POI hover preview card. +// CompactStructureCard — rendering tests for the structure hover preview card. // // Mirrors the convention established by the other component tests in // this folder (jsdom env + @testing-library/react + createElement so @@ -10,10 +10,10 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { createElement } from 'react'; -import { CompactPoiCard } from '../../../src/components/InfoCard/CompactPoiCard'; +import { CompactStructureCard } from '../../../src/components/InfoCard/CompactStructureCard'; import type { StructureRecord } from '../../../src/@types/engine/data/StructureRecord'; -// Fixture POI ~10 Mpc from origin so the distance formatter renders a +// Fixture structure ~10 Mpc from origin so the distance formatter renders a // readable Mpc value (formatDistance("Mpc / Mly") shape). physicalRadiusMpc // is 2.2 — a real-world Virgo-ish radius. const virgo: StructureRecord = { @@ -25,9 +25,9 @@ const virgo: StructureRecord = { physicalRadiusMpc: 2.2, }; -describe('CompactPoiCard', () => { - it('renders the POI name and category label', () => { - render(createElement(CompactPoiCard, { poi: virgo })); +describe('CompactStructureCard', () => { + it('renders the structure name and category label', () => { + render(createElement(CompactStructureCard, { structure: virgo })); expect(screen.getByText('Virgo Cluster')).toBeInTheDocument(); // Category badge — exact-match "Cluster" with title-case from the // inlined poiCategoryLabel helper. Exact match (not /cluster/i) @@ -37,7 +37,7 @@ describe('CompactPoiCard', () => { }); it('renders a distance row derived from |worldPos|', () => { - render(createElement(CompactPoiCard, { poi: virgo })); + render(createElement(CompactStructureCard, { structure: virgo })); // |[10, 0, 0]| = 10 Mpc. formatDistance renders "10.0 Mpc / 32.6 Mly" // (formatScalar uses one decimal between 10 and 100); assert on the // "Mpc" unit token so the test survives a future tweak to the @@ -46,7 +46,7 @@ describe('CompactPoiCard', () => { }); it('renders the physical radius when present', () => { - render(createElement(CompactPoiCard, { poi: virgo })); + render(createElement(CompactStructureCard, { structure: virgo })); // formatDistance(2.2) → "2.20 Mpc / 7.18 Mly". Match on the leading // "2.2" digits regardless of surrounding decimals so a future // formatScalar adjustment doesn't break the assertion. diff --git a/tests/components/InfoCard/DescriptionBlock.test.ts b/tests/components/InfoCard/DescriptionBlock.test.ts index 2badeae0a..590cbbd76 100644 --- a/tests/components/InfoCard/DescriptionBlock.test.ts +++ b/tests/components/InfoCard/DescriptionBlock.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom // // DescriptionBlock — the collapsible prose + show-more/less toggle shared by -// GalaxyDetailCard and PoiDetailCard. Verifies the toggle round-trips the +// GalaxyDetailCard and StructureDetailCard. Verifies the toggle round-trips the // collapse state and that the prose text is always rendered. // // jsdom env + @testing-library/react + createElement so the file stays .ts and diff --git a/tests/components/InfoCard/InfoCard.poiHover.test.ts b/tests/components/InfoCard/InfoCard.structureHover.test.ts similarity index 74% rename from tests/components/InfoCard/InfoCard.poiHover.test.ts rename to tests/components/InfoCard/InfoCard.structureHover.test.ts index f7f0e5489..b1575370c 100644 --- a/tests/components/InfoCard/InfoCard.poiHover.test.ts +++ b/tests/components/InfoCard/InfoCard.structureHover.test.ts @@ -1,22 +1,22 @@ // @vitest-environment jsdom // -// InfoCard unified hovered/selected props — routing tests covering the POI -// hover preview branch and its suppression rule. +// InfoCard unified hovered/selected props — routing tests covering the +// structure hover preview branch and its suppression rule. // // Since Task 5 of the unify-focus-clear refactor, InfoCard accepts a single // `hovered` and a single `selected` prop — each typed as // `GalaxyInfo | StructureRecord | null` (the `FocusableTarget` union). -// The component dispatches via `isPoi` internally. The old `hoveredPoi` / -// `selectedPoi` separate slots are gone. +// The component dispatches via `isPoi` internally. The old `hoveredStructure` +// / `selectedStructure` separate slots are gone. // // We assert on user-visible text rather than CSS-modules class fragments // because the CSS-modules-mangled class names aren't stable across // renames; rendered text is the stable contract for the user. // -// The "suppressed when same POI is pinned" case looks at the rendered -// DOM structure: the pinned FullCard shows "Pinned" in its title row, and +// The "suppressed when same structure is pinned" case looks at the rendered +// DOM structure: the pinned full card shows "Pinned" in its title row, and // the compact hover preview's title row says "Hover". We count the -// "Hover" occurrences — when the same POI is pinned, the suppression +// "Hover" occurrences — when the same structure is pinned, the suppression // rule should keep that count at zero. import { describe, it, expect } from 'vitest'; @@ -44,10 +44,9 @@ const coma: StructureRecord = { }; // Minimal GalaxyInfo stub for the "stack alongside pinned galaxy" case. -// FullCard / CompactCard read several fields; we only need enough for -// the FullCard's galaxy branch to render without throwing. Cast away -// the precise type because populating every field would obscure the -// test's intent. +// GalaxyDetailCard / CompactCard read several fields; we only need enough for +// the galaxy branch to render without throwing. Cast away the precise type +// because populating every field would obscure the test's intent. const galaxyStub = { index: 42, displayName: 'NGC 1234', @@ -56,8 +55,8 @@ const galaxyStub = { earthEra: 'Modern', distanceMpc: 100, galaxyType: { description: 'Spiral', category: 'spiral' }, - // Minimal fields needed by FullCard's galaxy branch — most are read - // and rendered; supplying zero / empty values keeps it from crashing. + // Minimal fields needed by the galaxy branch — most are read and + // rendered; supplying zero / empty values keeps it from crashing. objID: 0n, x: 0, y: 0, @@ -85,8 +84,8 @@ const galaxyStub = { thumbnailUrl: '', } as unknown as GalaxyInfo; -describe('InfoCard hoveredPoi prop', () => { - it('renders the POI hover preview when only hoveredPoi is set', () => { +describe('InfoCard hovered structure', () => { + it('renders the structure hover preview when only a hovered structure is set', () => { render( createElement(InfoCard, { hovered: virgo, @@ -100,7 +99,7 @@ describe('InfoCard hoveredPoi prop', () => { expect(screen.getByText('Hover')).toBeInTheDocument(); }); - it('suppresses the POI hover preview when the SAME POI is already pinned', () => { + it('suppresses the structure hover preview when the SAME structure is already pinned', () => { render( createElement(InfoCard, { hovered: virgo, @@ -113,11 +112,11 @@ describe('InfoCard hoveredPoi prop', () => { const matches = screen.getAllByText('Virgo Cluster'); expect(matches).toHaveLength(1); // And the suppression rule means no "Hover" eyebrow anywhere — the - // full POI card uses a "POI" eyebrow, not "Hover". + // full structure card uses a "Structure" eyebrow, not "Hover". expect(screen.queryByText('Hover')).not.toBeInTheDocument(); }); - it('shows the POI hover preview alongside a pinned DIFFERENT POI', () => { + it('shows the structure hover preview alongside a pinned DIFFERENT structure', () => { render( createElement(InfoCard, { hovered: virgo, @@ -130,15 +129,15 @@ describe('InfoCard hoveredPoi prop', () => { expect(screen.getByText('Virgo Cluster')).toBeInTheDocument(); }); - it('shows the POI hover preview alongside a pinned galaxy', () => { + it('shows the structure hover preview alongside a pinned galaxy', () => { render( createElement(InfoCard, { hovered: virgo, selected: galaxyStub, }), ); - // Virgo's name appears in the compact POI preview, stacked below the - // pinned galaxy's full card. + // Virgo's name appears in the compact structure preview, stacked below + // the pinned galaxy's full card. expect(screen.getByText('Virgo Cluster')).toBeInTheDocument(); expect(screen.getByText('NGC 1234')).toBeInTheDocument(); }); diff --git a/tests/components/InfoCard/PoiDetailCard.test.ts b/tests/components/InfoCard/StructureDetailCard.test.ts similarity index 72% rename from tests/components/InfoCard/PoiDetailCard.test.ts rename to tests/components/InfoCard/StructureDetailCard.test.ts index 957140fe1..08e936383 100644 --- a/tests/components/InfoCard/PoiDetailCard.test.ts +++ b/tests/components/InfoCard/StructureDetailCard.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom // -// PoiDetailCard — rendering tests for the rich focused-POI panel. +// StructureDetailCard — rendering tests for the rich focused-structure panel. // // Mirrors the sibling InfoCard component tests: jsdom env + // @testing-library/react + createElement so the file stays .ts and is @@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest'; import { render, screen } from '@testing-library/react'; import { createElement } from 'react'; -import { PoiDetailCard } from '../../../src/components/InfoCard/PoiDetailCard'; +import { StructureDetailCard } from '../../../src/components/InfoCard/StructureDetailCard'; import type { StructureRecord } from '../../../src/@types/engine/data/StructureRecord'; // Coma carries an Abell number ('A1656'); the card should expand it. @@ -33,38 +33,38 @@ const virgoNoAbell: StructureRecord = { physicalRadiusMpc: 2.2, }; -describe('PoiDetailCard', () => { +describe('StructureDetailCard', () => { it('shows the expanded Abell designation for a cluster carrying one', () => { - render(createElement(PoiDetailCard, { poi: comaWithAbell })); + render(createElement(StructureDetailCard, { structure: comaWithAbell })); expect(screen.getByText('Abell')).toBeInTheDocument(); expect(screen.getByText('Abell 1656')).toBeInTheDocument(); }); it('omits the Abell row for a cluster without an Abell designation', () => { - const { container } = render(createElement(PoiDetailCard, { poi: virgoNoAbell })); + const { container } = render(createElement(StructureDetailCard, { structure: virgoNoAbell })); expect(container.textContent).not.toMatch(/Abell/); }); it('shows the Galaxies row when a member count is supplied', () => { - render(createElement(PoiDetailCard, { poi: virgoNoAbell, memberCount: 42 })); + render(createElement(StructureDetailCard, { structure: virgoNoAbell, memberCount: 42 })); expect(screen.getByText('Galaxies')).toBeInTheDocument(); expect(screen.getByText('42')).toBeInTheDocument(); }); it('omits the Galaxies row when no member count is supplied', () => { - const { container } = render(createElement(PoiDetailCard, { poi: virgoNoAbell })); + const { container } = render(createElement(StructureDetailCard, { structure: virgoNoAbell })); expect(container.textContent).not.toMatch(/Galaxies/); }); it('omits the Galaxies row when the count is null (not yet computable)', () => { const { container } = render( - createElement(PoiDetailCard, { poi: virgoNoAbell, memberCount: null }), + createElement(StructureDetailCard, { structure: virgoNoAbell, memberCount: null }), ); expect(container.textContent).not.toMatch(/Galaxies/); }); it('renders a zero count truthfully (empty sphere over loaded data)', () => { - render(createElement(PoiDetailCard, { poi: virgoNoAbell, memberCount: 0 })); + render(createElement(StructureDetailCard, { structure: virgoNoAbell, memberCount: 0 })); expect(screen.getByText('Galaxies')).toBeInTheDocument(); expect(screen.getByText('0')).toBeInTheDocument(); }); From 517bbd4ef4640c7163c958128e789755a0cac805 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 23:14:11 +0200 Subject: [PATCH 4/6] docs(infocard): align mobile sheet spec with StructureDetailCard rename Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-07-mobile-info-card-bottom-sheet-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md b/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md index 8fb07c587..4e9bdb258 100644 --- a/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md +++ b/docs/superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md @@ -25,7 +25,7 @@ captured as a gated fast-follow (see the last section). - The scale bar no longer sits under the card. - **Desktop (≥ 768 px) is byte-for-byte unchanged** — same top-right card, same panels, same behaviour. -- Reuse the existing `GalaxyDetailCard` / `PoiDetailCard` bodies; don't fork a +- Reuse the existing `GalaxyDetailCard` / `StructureDetailCard` bodies; don't fork a parallel mobile card with its own drifting content. ## Non-goals @@ -79,7 +79,7 @@ adds no new state — only a reset. (If we ever want *zero* JS, the fallback is ### Hover on mobile Touch has no hover, so on mobile the sheet renders **only `selected`**. The -compact hover preview (`CompactCard` / `CompactPoiCard`) and the stacked-pair +compact hover preview (`CompactCard` / `CompactStructureCard`) and the stacked-pair layout are desktop-only and are suppressed below the breakpoint. `InfoCard` already returns null when nothing is selected; the mobile branch simply ignores `hovered`. @@ -97,7 +97,7 @@ height. No new state, no JS measurement. - **`InfoCard.tsx`** — add a mobile branch. Above the breakpoint: today's stack, unchanged. Below it: a `MobileSheet` wrapper around the single - `GalaxyDetailCard` / `PoiDetailCard` for `selected`, with `hovered` ignored. + `GalaxyDetailCard` / `StructureDetailCard` for `selected`, with `hovered` ignored. Layout switches via CSS media queries wherever possible; JS reads the breakpoint (via `matchMedia`) only for what CSS can't do — choosing the mobile render branch and the scroll-reset. The plan decides whether that read @@ -124,7 +124,7 @@ No changes to `GalaxyInfo`, `galaxyInfoBuilder`, the engine, or any data path. badge, the single distance line) is present and that "More details" reference rows are in the DOM (the scroll-snap reveal is CSS — we assert content presence, not pixel position, matching the existing - `InfoCard.poiHover.test.ts` philosophy of testing rendered text, not class + `InfoCard.structureHover.test.ts` philosophy of testing rendered text, not class fragments). - **Reset effect:** changing the `selected` target calls `scrollTo` on the container ref (spy). From f30da7e2e2e24c71e0f76496b5b31a895add4fe0 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 23:22:09 +0200 Subject: [PATCH 5/6] docs(backlog): add high-priority poi-free migration item Track the poi* -> structure* convergence as a high-priority surfaced issue: enumerate the engine/identity/URL holdouts, flag the #poi= deep-link param as a persisted format needing back-compat, and prescribe entanglement-radar (map) + /simplify (apply). Also note the mobile bottom-sheet spec under the existing reflow item. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/BACKLOG.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 13908cef6..4cd786a54 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -60,7 +60,14 @@ From [ADR 0001 §"explicitly not deciding"](adrs/0001-fade-ownership.md): Diagnosed but unplanned. Captured here so they don't get lost; promote to a spec or plan when prioritised. Most have richer notes in agent memory (`~/.claude/projects/-Users-rulkens-Development-js-skymap/memory/`). -- **Mobile layout reflow** — hover-on-touch is handled (`disable hover on touch input`, #226: hover-only affordances now route through tap). What remains is the general responsive layout pass: reflow the InfoCard / SettingsPanel / StatusBar for narrow viewports so the UI is usable on a phone, not just non-broken. +- **Make the codebase `poi`-free (`poi*` → `structure*`)** — **Priority: high.** The data layer is already `StructureRecord` and the InfoCard family was renamed to `StructureDetailCard` / `CompactStructureCard` (`structure` props, `Structure` eyebrow). The legacy `poi` vocabulary survives in the engine/identity/URL layers and reads as a second name for the same concept — the exact "two names, one thing" wart the simplicity convention flags. Holdouts found 2026-06-07: + - **`isPoi` predicate** (`services/engine/isPoi.ts`) — imported by ~8 non-InfoCard sites (`FocusableTarget.d.ts`, `EngineCallbacks.d.ts`, `EngineCameraHandle.d.ts`, `useUrlSync.ts`, `useStructureMemberCount.ts`, `engine.ts`, `commitFocus.ts` + tests). Rename to `isStructure`. + - **`POI_CATEGORY_INFO` / `data/poiCategoryInfo.ts` / `PoiCategory` type** (`@types/engine/data/PoiCategory.d.ts`) — the category-info table + its type alias; co-design with the `STRUCTURE_CATEGORY_META` consolidation in the DRY item below (don't merge the genuinely-different category *lists*). + - **`poiUrl` / `resolvePoiFromPick`** (`services/url/poiUrl.ts`, `services/engine/helpers/resolvePoiFromPick.ts`) and the engine `selectedPoi` params in `selectionRingPass.ts` / `EngineSubsystemHandles.d.ts` / `engine.ts`. + - **⚠️ The `#poi=` deep-link URL param** (`services/url/poiUrl.ts`, `utils/url/hasDeepLink.ts`, `App.tsx`) is a **persisted format** — existing shared links use `#poi=cluster-…`. Renaming the param string would break them, so this needs a back-compat read (accept both `#poi=` and a new `#structure=`) or must be deliberately left as-is. Decide explicitly; do **not** blind-rename. + + **Approach:** run `entanglement-radar` first to map the knots and the `#poi=` back-compat boundary, then apply via `/simplify` (delegate the edits per `feedback_simplify_edits_in_subagent`). Pairs with the `cluster* → structure*` naming migration and the structure-category-identity DRY item below — all three are the same "converge on `Structure*` vocabulary" cleanup. Mechanical apart from the URL-param decision; typecheck + tests are the safety net. +- **Mobile layout reflow** — hover-on-touch is handled (`disable hover on touch input`, #226: hover-only affordances now route through tap). What remains is the general responsive layout pass: reflow the InfoCard / SettingsPanel / StatusBar for narrow viewports so the UI is usable on a phone, not just non-broken. **Update 2026-06-07:** the InfoCard half is specced — [2026-06-07 mobile bottom-sheet design](superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md) (awaiting plan); the SettingsPanel launcher is a gated fast-follow pending an `entanglement-radar` pass over `SettingsPanel`. - **Lower-tier "close to home" weighting** — retune the small/medium tier subsampling so more galaxies survive near the camera's home position for maximum visual density on first load, while keeping the on-screen count fast. Distinct from the deliberate SDSS far-shell sample (memory `project_sdss_medium_intentionally_far`). - **Densely seed the Local Volume across all tiers (group explorability)** — surfaced 2026-06-04 with the `group` category. The 16 Local Volume groups are only interesting to fly into if their *member* galaxies are present, but `subsampleByAbsMag` (`tools/catalog/`) thins the nearby volume by absolute-magnitude cut, so faint dwarfs in the Local Group / M81 / Cen A / Sculptor etc. get culled — a group ring you focus into can be nearly empty at small/medium tier. Bias the subsampling to **keep galaxies inside (or near) the featured group spheres** regardless of `M_abs`, across small + medium and ideally large tiers, so each group has as many members as possible. Related to but distinct from the "close to home" weighting above: that's camera-home density; this is per-group membership density keyed off the structure seed. Implementation hooks: the group seed positions/radii (`data/cluster_anchors.seed.json`) are available to the build, so the subsampler can spare points within `apparentRadiusMpc` of each group centre. Keep an eye on the on-screen count budget. Pairs with the cluster-focus member count (`PoiDetailCard` "Galaxies" row) — denser seeding makes that number meaningful at lower tiers. - **Milliquas colour check** — Milliquas points currently all render blue; verify the colour-index / colour mapping for the quasar source isn't collapsing to a single hue. From 7d0c8edd006b876b3ab73d35108538545696c5cc Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Sun, 7 Jun 2026 23:27:38 +0200 Subject: [PATCH 6/6] fix(infocard): use SDSS quickobj for the catalogue link The explore/summary endpoint also 500s; quickobj is the lighter object page the link is meant to open. The earlier JsonSerializationException was SkyServer's scheduled-maintenance outage, not a quickobj defect. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/utils/math/sdssExplorerUrl.ts | 15 +++++++-------- tests/utils/math/sdssExplorerUrl.test.ts | 8 ++++---- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/utils/math/sdssExplorerUrl.ts b/src/utils/math/sdssExplorerUrl.ts index 139127167..829e14358 100644 --- a/src/utils/math/sdssExplorerUrl.ts +++ b/src/utils/math/sdssExplorerUrl.ts @@ -1,11 +1,10 @@ /** - * Build the SDSS DR18 Explore-tool "Summary" URL for a given object identifier. + * Build the SDSS DR18 "Quick Look" URL for a given object identifier. * - * The Explore Summary page is the canonical object page: it shows the image - * and spectrum, a summary of the photometric + spectroscopic measurements, and - * cross-survey links — a useful "click through to see more" target from the - * info card. We target Explore rather than the lightweight Quick Look - * (`quickobj`) endpoint, which returns a server error for some objIds. + * Quick Look (`quickobj`) is the lightweight single-object page: image, + * spectrum, and the headline photometric + spectroscopic measurements — a + * useful "click through to see more" target from the info card without the + * weight of the full Explore tool. * * `objId` is a 64-bit unsigned integer. We accept `bigint` to preserve full * precision — SDSS objIDs are 18-digit numbers that exceed Number's safe @@ -13,8 +12,8 @@ * truncate the last few digits and retrieve the wrong object. * * URL template (DR18): - * https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId={objId} + * https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId={objId} */ export function sdssExplorerUrl(objId: bigint): string { - return `https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=${objId}`; + return `https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=${objId}`; } diff --git a/tests/utils/math/sdssExplorerUrl.test.ts b/tests/utils/math/sdssExplorerUrl.test.ts index 339379dc6..ec1c7dc9f 100644 --- a/tests/utils/math/sdssExplorerUrl.test.ts +++ b/tests/utils/math/sdssExplorerUrl.test.ts @@ -1,6 +1,6 @@ /** * Unit tests for `sdssExplorerUrl` — pure URL builder for the SDSS DR18 - * Explore-tool Summary page. + * Quick Look (`quickobj`) page. * * The function takes a 64-bit `bigint` objID and interpolates it into a * canonical URL. Tests verify the URL shape and that bigint values @@ -12,11 +12,11 @@ import { describe, it, expect } from 'vitest'; import { sdssExplorerUrl } from '../../../src/utils/math/sdssExplorerUrl'; describe('sdssExplorerUrl', () => { - it('produces a DR18 Explore Summary URL for a small objID', () => { + it('produces a DR18 Quick Look URL for a small objID', () => { // Spot-check the literal URL template in the source. Any change to the // path or the query-param name would break this. expect(sdssExplorerUrl(42n)).toBe( - 'https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=42', + 'https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=42', ); }); @@ -34,7 +34,7 @@ describe('sdssExplorerUrl', () => { // GalaxyInfo layer, but the URL builder itself is permissive and just // string-interpolates the value. expect(sdssExplorerUrl(0n)).toBe( - 'https://skyserver.sdss.org/dr18/VisualTools/explore/summary?objId=0', + 'https://skyserver.sdss.org/dr18/VisualTools/quickobj?objId=0', ); }); });